From 52adae0126e6f6a17a88e9f1d53161173e5c4cbf Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 9 Nov 2026 13:42:42 -0800 Subject: [PATCH 1/2] feat(model): add plan block Co-Authored-By: Claude --- .../com/slack/api/model/block/Blocks.java | 6 + .../com/slack/api/model/block/PlanBlock.java | 44 ++++++ .../api/util/json/GsonLayoutBlockFactory.java | 2 + .../api/model/block/BlockKitTest.java | 145 ++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 slack-api-model/src/main/java/com/slack/api/model/block/PlanBlock.java diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/Blocks.java b/slack-api-model/src/main/java/com/slack/api/model/block/Blocks.java index d69c3f739..4ade40584 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/block/Blocks.java +++ b/slack-api-model/src/main/java/com/slack/api/model/block/Blocks.java @@ -159,6 +159,12 @@ public static ContainerBlock container(ModelConfigurator configurator) { + return configurator.configure(PlanBlock.builder()).build(); + } + // TaskCardBlock public static TaskCardBlock taskCard(ModelConfigurator configurator) { diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/PlanBlock.java b/slack-api-model/src/main/java/com/slack/api/model/block/PlanBlock.java new file mode 100644 index 000000000..db4af36a2 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/PlanBlock.java @@ -0,0 +1,44 @@ +package com.slack.api.model.block; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Displays a collection of related tasks. + * + * https://docs.slack.dev/reference/block-kit/blocks/plan-block + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PlanBlock implements LayoutBlock { + public static final String TYPE = "plan"; + + /** + * The type of block. For this block, type will always be {@code plan}. + */ + private final String type = TYPE; + + /** + * Title of the plan in plain text. + */ + private String title; + + /** + * A sequence of task card blocks, maximum of 50 tasks. Each one contains task-like objects without a type, + * and each {@code task_id} in a plan must be unique. + */ + private List tasks; + + /** + * A unique identifier for a block. If not specified, one will be generated. Maximum length for this field is + * 255 characters. {@code block_id} should be unique for each message and each iteration of a message. If a + * message is updated, use a new {@code block_id}. + */ + private String blockId; +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonLayoutBlockFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonLayoutBlockFactory.java index 43acc6033..7ee2d5c80 100644 --- a/slack-api-model/src/main/java/com/slack/api/util/json/GsonLayoutBlockFactory.java +++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonLayoutBlockFactory.java @@ -63,6 +63,8 @@ private Class getLayoutClassInstance(String typeName) { return InputBlock.class; case MarkdownBlock.TYPE: return MarkdownBlock.class; + case PlanBlock.TYPE: + return PlanBlock.class; case RichTextBlock.TYPE: return RichTextBlock.class; case SectionBlock.TYPE: diff --git a/slack-api-model/src/test/java/test_locally/api/model/block/BlockKitTest.java b/slack-api-model/src/test/java/test_locally/api/model/block/BlockKitTest.java index 5c6722a1c..61aa71c8c 100644 --- a/slack-api-model/src/test/java/test_locally/api/model/block/BlockKitTest.java +++ b/slack-api-model/src/test/java/test_locally/api/model/block/BlockKitTest.java @@ -2456,6 +2456,151 @@ public void buildTaskCardBlock() { assertThat(taskCard.getSources().size(), is(1)); } + @Test + public void parsePlanBlock() { + String json = "{\n" + + " \"blocks\": [\n" + + " {\n" + + " \"type\": \"plan\",\n" + + " \"title\": \"Thinking completed\",\n" + + " \"tasks\": [\n" + + " {\n" + + " \"task_id\": \"call_001\",\n" + + " \"title\": \"Fetched user profile information\",\n" + + " \"status\": \"in_progress\",\n" + + " \"details\": {\n" + + " \"type\": \"rich_text\",\n" + + " \"block_id\": \"viMWO\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"rich_text_section\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"text\",\n" + + " \"text\": \"Searched database...\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"output\": {\n" + + " \"type\": \"rich_text\",\n" + + " \"block_id\": \"viMWO\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"rich_text_section\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"text\",\n" + + " \"text\": \"Profile data loaded\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " }\n" + + " },\n" + + " {\n" + + " \"task_id\": \"call_002\",\n" + + " \"title\": \"Checked user permissions\",\n" + + " \"status\": \"in_progress\"\n" + + " },\n" + + " {\n" + + " \"task_id\": \"call_003\",\n" + + " \"title\": \"Generated comprehensive user report\",\n" + + " \"status\": \"complete\",\n" + + " \"output\": {\n" + + " \"type\": \"rich_text\",\n" + + " \"block_id\": \"crsk\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"rich_text_section\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"text\",\n" + + " \"text\": \"15 data points compiled\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + Gson gson = GsonFactory.createSnakeCase(); + Message message = gson.fromJson(json, Message.class); + assertThat(message, is(notNullValue())); + assertThat(message.getBlocks().size(), is(1)); + + PlanBlock plan = (PlanBlock) message.getBlocks().get(0); + assertThat(plan.getType(), is("plan")); + assertThat(plan.getTitle(), is("Thinking completed")); + assertThat(plan.getTasks().size(), is(3)); + + assertThat(plan.getTasks().get(0).getTaskId(), is("call_001")); + assertThat(plan.getTasks().get(0).getTitle(), is("Fetched user profile information")); + assertThat(plan.getTasks().get(0).getStatus(), is("in_progress")); + assertThat(plan.getTasks().get(0).getDetails().getBlockId(), is("viMWO")); + assertThat(plan.getTasks().get(0).getOutput().getBlockId(), is("viMWO")); + + assertThat(plan.getTasks().get(1).getTaskId(), is("call_002")); + assertThat(plan.getTasks().get(1).getTitle(), is("Checked user permissions")); + assertThat(plan.getTasks().get(1).getStatus(), is("in_progress")); + + assertThat(plan.getTasks().get(2).getTaskId(), is("call_003")); + assertThat(plan.getTasks().get(2).getTitle(), is("Generated comprehensive user report")); + assertThat(plan.getTasks().get(2).getStatus(), is("complete")); + assertThat(plan.getTasks().get(2).getOutput().getBlockId(), is("crsk")); + } + + @Test + public void buildPlanBlock() { + PlanBlock plan = Blocks.plan(p -> p + .title("Thinking completed") + .tasks(Arrays.asList( + Blocks.taskCard(t -> t + .taskId("call_001") + .title("Fetched user profile information") + .status("in_progress") + .details(Blocks.richText(r -> r + .blockId("viMWO") + .elements(Arrays.asList(RichTextSectionElement.builder() + .elements(Arrays.asList(RichTextSectionElement.Text.builder() + .text("Searched database...") + .build())) + .build())))) + .output(Blocks.richText(r -> r + .blockId("viMWO") + .elements(Arrays.asList(RichTextSectionElement.builder() + .elements(Arrays.asList(RichTextSectionElement.Text.builder() + .text("Profile data loaded") + .build())) + .build()))))), + Blocks.taskCard(t -> t + .taskId("call_002") + .title("Checked user permissions") + .status("in_progress")), + Blocks.taskCard(t -> t + .taskId("call_003") + .title("Generated comprehensive user report") + .status("complete") + .output(Blocks.richText(r -> r + .blockId("crsk") + .elements(Arrays.asList(RichTextSectionElement.builder() + .elements(Arrays.asList(RichTextSectionElement.Text.builder() + .text("15 data points compiled") + .build())) + .build())))))))); + assertThat(plan, is(notNullValue())); + assertThat(plan.getType(), is("plan")); + assertThat(plan.getTitle(), is("Thinking completed")); + assertThat(plan.getTasks().size(), is(3)); + assertThat(plan.getTasks().get(0).getTaskId(), is("call_001")); + assertThat(plan.getTasks().get(1).getTaskId(), is("call_002")); + assertThat(plan.getTasks().get(2).getTaskId(), is("call_003")); + } + @Test public void parseAuthProviderBlock() { String json = "{\"type\":\"home\",\"blocks\":[\n" + From 13735859690f23a9b6d7f8dd3811f86575dcbdd1 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 9 Nov 2026 14:34:04 -0800 Subject: [PATCH 2/2] chore(model): match sibling blocks without a block_id comment Co-Authored-By: Claude --- .../src/main/java/com/slack/api/model/block/PlanBlock.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/PlanBlock.java b/slack-api-model/src/main/java/com/slack/api/model/block/PlanBlock.java index db4af36a2..738589eef 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/block/PlanBlock.java +++ b/slack-api-model/src/main/java/com/slack/api/model/block/PlanBlock.java @@ -35,10 +35,5 @@ public class PlanBlock implements LayoutBlock { */ private List tasks; - /** - * A unique identifier for a block. If not specified, one will be generated. Maximum length for this field is - * 255 characters. {@code block_id} should be unique for each message and each iteration of a message. If a - * message is updated, use a new {@code block_id}. - */ private String blockId; }