From e4a94bf47d4d701d51d4c416de3f1ffcdb51561e Mon Sep 17 00:00:00 2001 From: Devin AI Date: Fri, 17 Apr 2026 20:23:09 +0000 Subject: [PATCH 01/10] docs(specs): add draft Slack rendering spec for render intents Formalize the design from issue #208 as a canonical draft spec covering: - Render-intent layer boundary between assistant output and Slack delivery - Three lanes: final reply, in-flight progress, durable entities - Plugin renderer registry contract (match/buildIntent/buildFallbackText/ buildActions/buildWorkObject) - SDK-first phasing using the installed chat/@chat-adapter/slack surfaces before adding Slack-specific block abstractions - Accessibility and fallback rules requiring top-level text for every block-bearing message - Failure model, degradation rules, and verification coverage targets The spec sits alongside slack-agent-delivery-spec.md and slack-outbound-contract-spec.md without changing their contracts, and is marked Draft because implementation has not landed yet. Refs: #208 Co-Authored-By: Claude Sonnet 4.5 --- AGENTS.md | 1 + specs/index.md | 2 + specs/slack-rendering-spec.md | 194 ++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 specs/slack-rendering-spec.md diff --git a/AGENTS.md b/AGENTS.md index f78fb0e9..c8aee75e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,7 @@ Co-Authored-By: (agent model name) - `specs/chat-architecture-spec.md` (chat composition, service, and test-seam architecture contract) - `specs/slack-agent-delivery-spec.md` (Slack entry surfaces, reply delivery, continuation, files, images, and resume behavior contract) - `specs/slack-outbound-contract-spec.md` (Slack outbound boundary, message/file/reaction safety rules, and markdown-to-`mrkdwn` ownership) +- `specs/slack-rendering-spec.md` (Slack render-intent layer, plugin renderer registry, and Work Object lane contract — draft) - `specs/skill-capabilities-spec.md` (capability declaration + broker/injection contract) - `specs/oauth-flows-spec.md` (OAuth authorization code flow + Slack UX contract) - `specs/harness-agent-spec.md` (agent loop and output contract) diff --git a/specs/index.md b/specs/index.md index 281fad49..313ce20a 100644 --- a/specs/index.md +++ b/specs/index.md @@ -14,6 +14,7 @@ - 2026-03-21: Added canonical chat architecture spec. - 2026-04-15: Added canonical Slack agent delivery spec. - 2026-04-16: Added canonical Slack write contract spec. +- 2026-04-17: Added draft Slack rendering spec for render intents, plugin renderer registry, and Work Object lanes. ## Status @@ -48,6 +49,7 @@ Define spec taxonomy, naming conventions, and canonical source-of-truth document - `specs/chat-architecture-spec.md` - `specs/slack-agent-delivery-spec.md` - `specs/slack-outbound-contract-spec.md` +- `specs/slack-rendering-spec.md` - `specs/skill-capabilities-spec.md` - `specs/oauth-flows-spec.md` - `specs/harness-agent-spec.md` diff --git a/specs/slack-rendering-spec.md b/specs/slack-rendering-spec.md new file mode 100644 index 00000000..1b489db1 --- /dev/null +++ b/specs/slack-rendering-spec.md @@ -0,0 +1,194 @@ +# Slack Rendering Spec + +## Metadata + +- Created: 2026-04-17 +- Last Edited: 2026-04-17 + +## Changelog + +- 2026-04-17: Initial draft of the render-intent layer, plugin renderer registry, and Work Object boundary for Slack delivery. + +## Status + +Draft + +## Purpose + +Define the canonical rendering contract that sits between Junior's assistant output and Slack delivery so visible replies, in-flight progress, and durable entities use the same presentation boundary. + +This spec exists so Slack presentation choices are expressed as high-level render intents owned by core code, not as raw Block Kit JSON authored by the model or duplicated inside plugins. + +It is a sibling to `slack-agent-delivery-spec.md` (delivery semantics) and `slack-outbound-contract-spec.md` (outbound Slack API safety). Those specs keep ownership of the reply-delivery contract and raw Slack writes. This spec only adds the presentation layer in front of them. + +## Scope + +- The internal render-intent types that Junior produces before delivery. +- The three rendering lanes: final reply surfaces, in-flight progress, and durable entities. +- The plugin renderer registry contract for domain objects. +- Fallback, degradation, and accessibility rules for Slack-native blocks. +- Verification shape for render-intent selection, plugin renderer resolution, and fallback behavior. + +## Non-Goals + +- Replacing the visible reply delivery contract defined in `slack-agent-delivery-spec.md`. +- Replacing the outbound boundary defined in `slack-outbound-contract-spec.md`. +- Reintroducing visible freeform text streaming as the default reply path. +- Letting the model author arbitrary Slack Block Kit payloads directly. +- Encoding presentation data inside plugin manifests as static JSON. +- Treating logs, tracing, or status telemetry as rendering contracts. +- Specifying chart or image-generation surfaces. Slack still receives those as image attachments with a concise textual takeaway. + +## Contracts + +### 1. Render-Intent Boundary + +1. Junior core owns a closed set of render-intent types. The initial set is: + - `plain_reply` + - `alert` + - `summary_card` + - `comparison_table` + - `result_carousel` + - `progress_plan` + - `work_object_reference` + +2. The model chooses an intent kind and supplies structured fields. The model does not author Slack blocks. +3. Core code translates each render intent into a Slack view model plus top-level fallback text before calling the outbound boundary. +4. A reply emits at most one hero render intent per message. Additional intents for the same turn must split into separate chunks using the existing reply planner, not stack inside one message. +5. Intents that cannot be fully realized (missing fields, unsupported block version, Slack payload limits) must degrade to a lower-fidelity intent in the same lane rather than silently drop content. Degradation order is defined per lane below. + +### 2. Lane Separation + +Render intents are partitioned into three lanes. Each lane has its own policy and cannot borrow intents from another lane. + +1. Final reply lane + - Allowed intents: `plain_reply`, `alert`, `summary_card`, `comparison_table`, `result_carousel`, `work_object_reference`. + - Governed by the finalized-reply contract in `slack-agent-delivery-spec.md`. + - Must always produce top-level fallback `text` through the shared outbound boundary. +2. In-flight progress lane + - Allowed surfaces: assistant status, optional `progress_plan` rendered via the Chat SDK `Plan` object, optional `task_card`-style streamed chunks (`task_update`, `plan_update`). + - Must not carry the final answer. A completed `Plan` is not a substitute for the final reply. + - Progress lane writes remain best effort and must not sit on the critical path for tool/model execution, per the long-running status contract. +3. Durable entity lane + - Allowed intent: `work_object_reference`. + - Backed by Slack Work Objects for records users will revisit, search, expand, and act on. + - The message that references a Work Object must still carry meaningful fallback text and, when the Work Object cannot be created or resolved, must degrade to `summary_card` in the final reply lane. + +### 3. Intent Selection Rules + +The model is instructed to choose a render intent from the final reply lane or the durable entity lane using the following selection rules. These rules are the product contract for intent choice. + +1. `alert`: the main point is urgency, risk, a blocker, an authorization requirement, a destructive decision, or a partial failure that changes what the user should do next. +2. `summary_card`: the answer is best understood as one record with status, owner, priority, and 1-3 next actions. +3. `comparison_table`: the answer is driven by small structured comparisons that remain scannable. Tables must not be used as a formatting trick for prose. +4. `result_carousel`: the user asked for a small set (2-5) of comparable objects to browse, inspect, or pick from. +5. `progress_plan`: multi-step long-running work where structured task state is useful before the final reply. Progress-lane only. +6. `work_object_reference`: the object is durable and should live in Slack as a first-class entity, not as a one-off message. Must come from a plugin renderer that declares `buildWorkObject`. +7. `plain_reply`: used when none of the rich intents add value, or when the answer is prose. + +Intent selection is a product contract. It replaces the current "emit `slack-mrkdwn` and avoid tables" prompt bias for final replies. + +### 4. Core Render Pipeline + +1. Assistant output declares a render intent and structured fields. +2. Core resolves the intent to a Slack view model by: + - calling the matching plugin renderer when the intent carries a plugin-domain entity, or + - falling back to a core renderer for generic intents (`plain_reply`, `alert`, `summary_card`, `comparison_table`, `result_carousel`). +3. Core always derives a top-level `text` fallback from the same structured fields used to build blocks. +4. Core passes the resulting blocks plus fallback text to the shared outbound boundary in `slack-outbound-contract-spec.md`. Outbound rendering rules, chunking, and footer attachment are unchanged. +5. Render-time failures (unknown intent, renderer threw, payload exceeds Slack block limits) must degrade to `plain_reply` using the same fallback text. Degradation is an observable event, not a silent no-op. + +### 5. Plugin Renderer Registry + +1. Plugins may register one or more renderers. Renderers are code-owned TypeScript modules loaded from the plugin, not static YAML. +2. A renderer declares: + - `match(result, context): number | null` — returns a score for the given domain entity or `null` if the renderer does not apply. + - `buildIntent(entity, context): SlackRenderIntent` — returns the intent kind plus structured fields in the final reply lane. + - `buildFallbackText(entity, context): string` — returns the top-level `text` fallback. Must always be non-empty when the renderer matches. + - `buildActions?(entity, context): SlackRenderAction[]` — optional. Must not exceed Slack's documented action limits for the selected block set. + - `buildWorkObject?(entity, context): WorkObjectPayload` — optional. Required for `work_object_reference` intents. +3. When multiple renderers match, the highest score wins. Ties are resolved by plugin registration order, which is the existing deterministic plugin load order. +4. Plugins do not author Slack Block Kit payloads. They author render intents plus structured fields. Slack block construction stays in core. +5. Renderers are invoked only after a skill from the same plugin is loaded for the turn, matching the MCP tool activation rule in `plugin-spec.md`. This keeps rendering surface alignment with active capabilities. +6. Renderer modules must not perform network calls. All data needed to render must already be on the entity supplied by the tool or skill. + +### 6. SDK-First Phasing + +1. Phase 1 uses capabilities already present in the installed `chat` / `@chat-adapter/slack` packages before adding Slack-specific block abstractions: + - `Card(...)` for `summary_card`. + - The Chat SDK `Table` element for `comparison_table`. + - The Chat SDK `Plan` object plus `task_update` / `plan_update` stream chunks for `progress_plan`. + - Final-message actions where follow-up workflows matter. +2. Phase 2 adds a Slack-specific renderer layer for blocks that are newer than the current Chat SDK abstractions. Initial targets are `alert`, `card`, and `carousel`. This layer sits behind the outbound boundary and must always emit top-level `text`. It must degrade cleanly when Slack block support or SDK support is unavailable. +3. Phase 3 adds Work Object support for durable records. Likely first candidates are Sentry incident, Sentry issue, Linear task, and GitHub issue or PR. Work Object creation must be plugin-owned and must not run on the critical path of final reply delivery. + +The phased ordering is a contract: Phase 2 and Phase 3 additions must not land before the Phase 1 render-intent layer is the canonical path for final replies. + +### 7. Accessibility and Fallback Rules + +1. Every message delivered through the render-intent layer must carry a non-empty top-level `text` derived from the same structured fields as the blocks. +2. Alerts, summary cards, comparison tables, and carousels must be understandable from the fallback text alone. The blocks are an enhancement, not the sole carrier of meaning. +3. Actions must have clear text labels. Icon-only actions are not allowed. +4. For chart-like content, Slack delivery continues to use image uploads plus a concise textual takeaway and a download path. The render-intent layer does not introduce a chart intent in this spec. +5. Work Object references must still include enough fallback text to explain the record identity, state, and primary action when Slack cannot render the Work Object inline. + +### 8. Prompt and Model Behavior + +1. The prompt instructs the model to choose a render intent from the final reply lane instead of authoring `slack-mrkdwn` for structured answers. +2. The prompt lists the selection rules from section 3 with short examples and lets the model fall back to `plain_reply` when no rich intent fits. +3. The prompt must not describe Slack Block Kit JSON shapes or let the model author blocks. +4. Progress-lane affordances (status, `progress_plan`) are offered to the model only when the turn is expected to be long-running or tool-heavy, consistent with the long-running status contract. + +### 9. Plugin Template Coverage + +First-party plugin renderer coverage targets, documented as a contract so plugin authors know what is expected: + +1. Sentry: issue summary card, incident summary card, regression alert, issue search-results carousel, incident Work Object, issue Work Object. +2. Linear: task summary card, project or initiative carousel, task Work Object. +3. GitHub: PR summary card, issue summary card, review queue carousel, issue or PR Work Object. + +Coverage is additive. Missing a template for a specific entity must degrade to `plain_reply` with fallback text rather than block delivery. + +## Failure Model + +1. Unknown render intent, renderer throws, or view-model construction fails: degrade to `plain_reply` using the same structured fields' fallback text. Record the degradation with the failing intent kind and plugin name. +2. Slack block payload exceeds platform limits: degrade to the next lower-fidelity intent in the same lane (`result_carousel` -> `summary_card` -> `plain_reply`; `comparison_table` -> `plain_reply`; `alert` -> `plain_reply`). Never silently truncate blocks. +3. Work Object creation fails or is unsupported by the workspace: degrade to `summary_card` with an "open in source" action and preserve fallback text. +4. Missing top-level `text` fallback is a contract violation. The outbound boundary must reject messages that attach blocks without fallback text. +5. Plugin renderer attempts a network call or depends on ambient state: contract violation. Renderers must be pure over their arguments. +6. Progress-lane writes that fail (status update, plan update) must remain best effort. They must not fail the turn or block final reply delivery. + +## Observability + +Render-intent decisions and degradations must be observable without adding behavior contracts to logs. + +Required observability shape: + +- `app.render.intent` identifies the chosen intent kind for a final reply. +- `app.render.lane` identifies the lane (`final_reply`, `progress`, `durable_entity`). +- `app.render.plugin` identifies the plugin owning the renderer when applicable. +- `app.render.degraded_from` and `app.render.degraded_to` identify degradation transitions. +- Work Object creation emits a dedicated event so Work Object failures are distinguishable from message-post failures. + +Logs and spans remain governed by `specs/logging/index.md`. This spec does not add new behavior contracts backed by log assertions. + +## Verification + +Required verification coverage for this contract: + +1. Unit: core render-intent to Slack view-model translation for each intent kind, including fallback text derivation. +2. Unit: degradation paths (unknown intent, oversized payload, Work Object failure) produce the expected lower-fidelity intent. +3. Unit: plugin renderer registry scoring, tie-breaking by registration order, and activation gating behind loaded skills. +4. Integration: final reply lane produces one hero intent per message and always includes top-level fallback text through the shared outbound boundary. +5. Integration: progress-lane writes remain best effort and do not block or fail the turn. +6. Integration: Work Object references degrade to `summary_card` when creation fails and preserve fallback text. +7. Evals: realistic Slack conversations confirm the model selects the correct intent for alerts, single-record answers, small comparisons, small result sets, and long-running tool work. + +## Related Specs + +- `./slack-agent-delivery-spec.md` +- `./slack-outbound-contract-spec.md` +- `./chat-architecture-spec.md` +- `./plugin-spec.md` +- `./logging/index.md` +- `./testing/index.md` From e5e6fb629531690b09a0892652fca952860f876a Mon Sep 17 00:00:00 2001 From: Devin AI Date: Fri, 17 Apr 2026 20:37:26 +0000 Subject: [PATCH 02/10] docs(specs): drop work objects, make intents native, plugins teach via SKILL.md - Remove work_object_reference intent and the durable-entity lane. - Replace the plugin renderer registry with a guidance model: plugins influence rendering through SKILL.md content, not code or YAML templates. Rendering code stays in core; the intent palette is not plugin-extensible. - Collapse lanes to two: final reply and in-flight progress. - Rework the core render pipeline around one core renderer per intent kind with schema validation and plain_reply degradation on failure. - Drop the work-object observability attribute and durable_entity lane label. Drop work-object coverage from first-party targets. Refs: #208 Co-Authored-By: Claude Sonnet 4.5 --- specs/slack-rendering-spec.md | 146 ++++++++++++++++------------------ 1 file changed, 69 insertions(+), 77 deletions(-) diff --git a/specs/slack-rendering-spec.md b/specs/slack-rendering-spec.md index 1b489db1..d1d0d3fe 100644 --- a/specs/slack-rendering-spec.md +++ b/specs/slack-rendering-spec.md @@ -8,6 +8,7 @@ ## Changelog - 2026-04-17: Initial draft of the render-intent layer, plugin renderer registry, and Work Object boundary for Slack delivery. +- 2026-04-17: Dropped Work Objects. Replaced the declarative plugin-template registry with a native-intent palette the model selects from; plugins now teach intent usage through SKILL.md rather than YAML templates. Collapsed durable-entity lane; lanes are now final reply and in-flight progress. ## Status @@ -15,19 +16,19 @@ Draft ## Purpose -Define the canonical rendering contract that sits between Junior's assistant output and Slack delivery so visible replies, in-flight progress, and durable entities use the same presentation boundary. +Define the canonical rendering contract between Junior's assistant output and Slack delivery so visible replies and in-flight progress use the same presentation boundary. -This spec exists so Slack presentation choices are expressed as high-level render intents owned by core code, not as raw Block Kit JSON authored by the model or duplicated inside plugins. +Slack presentation choices are expressed as a small, closed palette of render intents owned by core code. The model selects an intent and supplies structured fields. Core turns that into Slack blocks and top-level fallback text before calling the outbound boundary. -It is a sibling to `slack-agent-delivery-spec.md` (delivery semantics) and `slack-outbound-contract-spec.md` (outbound Slack API safety). Those specs keep ownership of the reply-delivery contract and raw Slack writes. This spec only adds the presentation layer in front of them. +This spec sits in front of `slack-agent-delivery-spec.md` (reply delivery semantics) and `slack-outbound-contract-spec.md` (outbound Slack API safety). It does not change either of those contracts. ## Scope -- The internal render-intent types that Junior produces before delivery. -- The three rendering lanes: final reply surfaces, in-flight progress, and durable entities. -- The plugin renderer registry contract for domain objects. +- The native render-intent palette that Junior exposes to the model. +- The two rendering lanes: final reply and in-flight progress. +- How plugins influence intent usage for their domain objects. - Fallback, degradation, and accessibility rules for Slack-native blocks. -- Verification shape for render-intent selection, plugin renderer resolution, and fallback behavior. +- Verification shape for intent selection, block translation, and fallback behavior. ## Non-Goals @@ -35,94 +36,88 @@ It is a sibling to `slack-agent-delivery-spec.md` (delivery semantics) and `slac - Replacing the outbound boundary defined in `slack-outbound-contract-spec.md`. - Reintroducing visible freeform text streaming as the default reply path. - Letting the model author arbitrary Slack Block Kit payloads directly. +- Shipping a declarative plugin template DSL for rendering. Plugins are YAML manifests plus SKILL.md content; rendering code lives in core, and plugins teach intent usage through prose and examples. +- Supporting Slack Work Objects in this revision. Work Objects are a separate Slack primitive for durable, actionable records; they are out of scope until the render-intent layer has shipped end-to-end and there is a clear product reason to own upstream-state sync. - Encoding presentation data inside plugin manifests as static JSON. - Treating logs, tracing, or status telemetry as rendering contracts. - Specifying chart or image-generation surfaces. Slack still receives those as image attachments with a concise textual takeaway. ## Contracts -### 1. Render-Intent Boundary +### 1. Render-Intent Palette -1. Junior core owns a closed set of render-intent types. The initial set is: +1. Junior core owns a closed set of render-intent kinds. The initial set is: - `plain_reply` - `alert` - `summary_card` - `comparison_table` - `result_carousel` - `progress_plan` - - `work_object_reference` -2. The model chooses an intent kind and supplies structured fields. The model does not author Slack blocks. -3. Core code translates each render intent into a Slack view model plus top-level fallback text before calling the outbound boundary. -4. A reply emits at most one hero render intent per message. Additional intents for the same turn must split into separate chunks using the existing reply planner, not stack inside one message. -5. Intents that cannot be fully realized (missing fields, unsupported block version, Slack payload limits) must degrade to a lower-fidelity intent in the same lane rather than silently drop content. Degradation order is defined per lane below. +2. Intents are native primitives exposed to the model through the turn's structured output surface, alongside tool calls. The model selects an intent kind and supplies structured fields. The model does not author Slack blocks. + +3. Core translates each render intent into a Slack view model plus top-level fallback `text` before calling the outbound boundary. Block construction, block limits, and Slack-specific shape stay in core. + +4. A final reply emits at most one hero render intent per message. Additional intents for the same turn must split into separate chunks through the existing reply planner, not stack inside one message. + +5. Intents that cannot be fully realized (missing fields, unsupported block version, Slack payload limits) must degrade to a lower-fidelity intent in the same lane rather than silently drop content. Degradation order is defined per lane in the Failure Model section. ### 2. Lane Separation -Render intents are partitioned into three lanes. Each lane has its own policy and cannot borrow intents from another lane. +Render intents are partitioned into two lanes. Each lane has its own policy and cannot borrow intents from another lane. 1. Final reply lane - - Allowed intents: `plain_reply`, `alert`, `summary_card`, `comparison_table`, `result_carousel`, `work_object_reference`. + - Allowed intents: `plain_reply`, `alert`, `summary_card`, `comparison_table`, `result_carousel`. - Governed by the finalized-reply contract in `slack-agent-delivery-spec.md`. - Must always produce top-level fallback `text` through the shared outbound boundary. + 2. In-flight progress lane - - Allowed surfaces: assistant status, optional `progress_plan` rendered via the Chat SDK `Plan` object, optional `task_card`-style streamed chunks (`task_update`, `plan_update`). + - Allowed surfaces: assistant status, `progress_plan` rendered via the Chat SDK `Plan` object, optional `task_update` / `plan_update` stream chunks. - Must not carry the final answer. A completed `Plan` is not a substitute for the final reply. - - Progress lane writes remain best effort and must not sit on the critical path for tool/model execution, per the long-running status contract. -3. Durable entity lane - - Allowed intent: `work_object_reference`. - - Backed by Slack Work Objects for records users will revisit, search, expand, and act on. - - The message that references a Work Object must still carry meaningful fallback text and, when the Work Object cannot be created or resolved, must degrade to `summary_card` in the final reply lane. + - Progress-lane writes remain best effort and must not sit on the critical path for tool or model execution, per the long-running status contract. ### 3. Intent Selection Rules -The model is instructed to choose a render intent from the final reply lane or the durable entity lane using the following selection rules. These rules are the product contract for intent choice. +The model is instructed to choose a render intent from the final reply lane using the following selection rules. These rules are the product contract for intent choice. 1. `alert`: the main point is urgency, risk, a blocker, an authorization requirement, a destructive decision, or a partial failure that changes what the user should do next. 2. `summary_card`: the answer is best understood as one record with status, owner, priority, and 1-3 next actions. 3. `comparison_table`: the answer is driven by small structured comparisons that remain scannable. Tables must not be used as a formatting trick for prose. 4. `result_carousel`: the user asked for a small set (2-5) of comparable objects to browse, inspect, or pick from. 5. `progress_plan`: multi-step long-running work where structured task state is useful before the final reply. Progress-lane only. -6. `work_object_reference`: the object is durable and should live in Slack as a first-class entity, not as a one-off message. Must come from a plugin renderer that declares `buildWorkObject`. -7. `plain_reply`: used when none of the rich intents add value, or when the answer is prose. +6. `plain_reply`: used when none of the rich intents add value, or when the answer is prose. -Intent selection is a product contract. It replaces the current "emit `slack-mrkdwn` and avoid tables" prompt bias for final replies. +Intent selection replaces the current "emit `slack-mrkdwn` and avoid tables" prompt bias for final replies. `plain_reply` preserves the current mrkdwn rendering path so no behavior changes for replies that stay prose. ### 4. Core Render Pipeline -1. Assistant output declares a render intent and structured fields. -2. Core resolves the intent to a Slack view model by: - - calling the matching plugin renderer when the intent carries a plugin-domain entity, or - - falling back to a core renderer for generic intents (`plain_reply`, `alert`, `summary_card`, `comparison_table`, `result_carousel`). -3. Core always derives a top-level `text` fallback from the same structured fields used to build blocks. -4. Core passes the resulting blocks plus fallback text to the shared outbound boundary in `slack-outbound-contract-spec.md`. Outbound rendering rules, chunking, and footer attachment are unchanged. -5. Render-time failures (unknown intent, renderer threw, payload exceeds Slack block limits) must degrade to `plain_reply` using the same fallback text. Degradation is an observable event, not a silent no-op. - -### 5. Plugin Renderer Registry - -1. Plugins may register one or more renderers. Renderers are code-owned TypeScript modules loaded from the plugin, not static YAML. -2. A renderer declares: - - `match(result, context): number | null` — returns a score for the given domain entity or `null` if the renderer does not apply. - - `buildIntent(entity, context): SlackRenderIntent` — returns the intent kind plus structured fields in the final reply lane. - - `buildFallbackText(entity, context): string` — returns the top-level `text` fallback. Must always be non-empty when the renderer matches. - - `buildActions?(entity, context): SlackRenderAction[]` — optional. Must not exceed Slack's documented action limits for the selected block set. - - `buildWorkObject?(entity, context): WorkObjectPayload` — optional. Required for `work_object_reference` intents. -3. When multiple renderers match, the highest score wins. Ties are resolved by plugin registration order, which is the existing deterministic plugin load order. -4. Plugins do not author Slack Block Kit payloads. They author render intents plus structured fields. Slack block construction stays in core. -5. Renderers are invoked only after a skill from the same plugin is loaded for the turn, matching the MCP tool activation rule in `plugin-spec.md`. This keeps rendering surface alignment with active capabilities. -6. Renderer modules must not perform network calls. All data needed to render must already be on the entity supplied by the tool or skill. +1. Assistant output declares a render intent kind plus structured fields appropriate to that intent. +2. Core validates the intent payload against the declared schema for that kind. Validation failure degrades to `plain_reply` with the structured-field-derived fallback text. +3. Core resolves the intent to a Slack view model through the core renderer for that intent kind. There is one core renderer per intent kind; renderers are owned by core, not plugins. +4. Core always derives a top-level `text` fallback from the same structured fields used to build blocks. +5. Core passes the resulting blocks plus fallback text to the shared outbound boundary in `slack-outbound-contract-spec.md`. Outbound rendering rules, chunking, and footer attachment are unchanged. +6. Render-time failures (unknown intent kind, validation failed, payload exceeds Slack block limits) must degrade to `plain_reply` using the same fallback text. Degradation is an observable event, not a silent no-op. + +### 5. Plugin Guidance Model + +1. Plugins do not register renderers, templates, or presentation YAML. Rendering code stays in core. +2. Plugins influence intent usage through their existing surface area: + - `SKILL.md` content teaches the model when a plugin's domain objects are best expressed as which intent, with short examples of the structured fields the model should populate. + - Plugin-owned prompt snippets, where present, may reference the intent names directly but must not describe Slack block shapes. +3. This keeps the plugin contract declarative (YAML manifest plus markdown skills) and preserves the principle that plugins are not code. +4. Adding support for a new plugin-domain entity in Slack rendering does not require any core change when the existing intent set already covers the presentation. The work is an edit to that plugin's `SKILL.md`. +5. Adding a new intent kind is a core change. Intents are the shared vocabulary; the palette is not plugin-extensible. ### 6. SDK-First Phasing -1. Phase 1 uses capabilities already present in the installed `chat` / `@chat-adapter/slack` packages before adding Slack-specific block abstractions: - - `Card(...)` for `summary_card`. - - The Chat SDK `Table` element for `comparison_table`. - - The Chat SDK `Plan` object plus `task_update` / `plan_update` stream chunks for `progress_plan`. +1. Phase 1 uses capabilities already present in the installed `chat` and `@chat-adapter/slack` packages before adding Slack-specific block abstractions: + - Chat SDK `Card(...)` for `summary_card`. + - Chat SDK `Table` element for `comparison_table`. + - Chat SDK `Plan` object plus `task_update` / `plan_update` stream chunks for `progress_plan`. - Final-message actions where follow-up workflows matter. -2. Phase 2 adds a Slack-specific renderer layer for blocks that are newer than the current Chat SDK abstractions. Initial targets are `alert`, `card`, and `carousel`. This layer sits behind the outbound boundary and must always emit top-level `text`. It must degrade cleanly when Slack block support or SDK support is unavailable. -3. Phase 3 adds Work Object support for durable records. Likely first candidates are Sentry incident, Sentry issue, Linear task, and GitHub issue or PR. Work Object creation must be plugin-owned and must not run on the critical path of final reply delivery. +2. Phase 2 adds a Slack-specific renderer layer for blocks that are newer than the current Chat SDK abstractions. Initial targets are `alert`, richer `card` variants, and `carousel`. This layer sits behind the outbound boundary and must always emit top-level `text`. It must degrade cleanly when Slack block support or SDK support is unavailable. -The phased ordering is a contract: Phase 2 and Phase 3 additions must not land before the Phase 1 render-intent layer is the canonical path for final replies. +The phased ordering is a contract: Phase 2 additions must not land before the Phase 1 render-intent palette is the canonical path for final replies. ### 7. Accessibility and Fallback Rules @@ -130,33 +125,32 @@ The phased ordering is a contract: Phase 2 and Phase 3 additions must not land b 2. Alerts, summary cards, comparison tables, and carousels must be understandable from the fallback text alone. The blocks are an enhancement, not the sole carrier of meaning. 3. Actions must have clear text labels. Icon-only actions are not allowed. 4. For chart-like content, Slack delivery continues to use image uploads plus a concise textual takeaway and a download path. The render-intent layer does not introduce a chart intent in this spec. -5. Work Object references must still include enough fallback text to explain the record identity, state, and primary action when Slack cannot render the Work Object inline. ### 8. Prompt and Model Behavior -1. The prompt instructs the model to choose a render intent from the final reply lane instead of authoring `slack-mrkdwn` for structured answers. +1. The prompt teaches the model to choose a render intent from the final reply lane instead of authoring `slack-mrkdwn` for structured answers. 2. The prompt lists the selection rules from section 3 with short examples and lets the model fall back to `plain_reply` when no rich intent fits. -3. The prompt must not describe Slack Block Kit JSON shapes or let the model author blocks. +3. The prompt does not describe Slack Block Kit JSON shapes and does not let the model author blocks. 4. Progress-lane affordances (status, `progress_plan`) are offered to the model only when the turn is expected to be long-running or tool-heavy, consistent with the long-running status contract. +5. Plugin `SKILL.md` content extends the selection rules with plugin-specific guidance (for example, "when returning a GitHub pull request, a `summary_card` with title, author, state, and review status is usually best"). Plugin guidance cannot expand or redefine the palette itself. -### 9. Plugin Template Coverage +### 9. First-Party Coverage Targets -First-party plugin renderer coverage targets, documented as a contract so plugin authors know what is expected: +First-party plugin intent-usage coverage, documented so plugin authors know what is expected from their `SKILL.md` and prompt content: -1. Sentry: issue summary card, incident summary card, regression alert, issue search-results carousel, incident Work Object, issue Work Object. -2. Linear: task summary card, project or initiative carousel, task Work Object. -3. GitHub: PR summary card, issue summary card, review queue carousel, issue or PR Work Object. +1. Sentry: issue summary card, incident summary card, regression alert, issue search-results carousel. +2. Linear: task summary card, project or initiative carousel. +3. GitHub: PR summary card, issue summary card, review queue carousel. -Coverage is additive. Missing a template for a specific entity must degrade to `plain_reply` with fallback text rather than block delivery. +Coverage is additive. A plugin missing guidance for a specific entity is not a violation; the model falls back to `plain_reply` with prose. ## Failure Model -1. Unknown render intent, renderer throws, or view-model construction fails: degrade to `plain_reply` using the same structured fields' fallback text. Record the degradation with the failing intent kind and plugin name. +1. Unknown render-intent kind or intent payload fails validation: degrade to `plain_reply` using fallback text derived from the structured fields. Record the degradation with the failing intent kind. 2. Slack block payload exceeds platform limits: degrade to the next lower-fidelity intent in the same lane (`result_carousel` -> `summary_card` -> `plain_reply`; `comparison_table` -> `plain_reply`; `alert` -> `plain_reply`). Never silently truncate blocks. -3. Work Object creation fails or is unsupported by the workspace: degrade to `summary_card` with an "open in source" action and preserve fallback text. -4. Missing top-level `text` fallback is a contract violation. The outbound boundary must reject messages that attach blocks without fallback text. -5. Plugin renderer attempts a network call or depends on ambient state: contract violation. Renderers must be pure over their arguments. -6. Progress-lane writes that fail (status update, plan update) must remain best effort. They must not fail the turn or block final reply delivery. +3. Missing top-level `text` fallback is a contract violation. The outbound boundary must reject messages that attach blocks without fallback text. +4. Core renderer throws: degrade to `plain_reply` with the structured-field-derived fallback text and record the failure. Renderer exceptions must not surface as Slack post failures. +5. Progress-lane writes that fail (status update, plan update) must remain best effort. They must not fail the turn or block final reply delivery. ## Observability @@ -165,24 +159,22 @@ Render-intent decisions and degradations must be observable without adding behav Required observability shape: - `app.render.intent` identifies the chosen intent kind for a final reply. -- `app.render.lane` identifies the lane (`final_reply`, `progress`, `durable_entity`). -- `app.render.plugin` identifies the plugin owning the renderer when applicable. +- `app.render.lane` identifies the lane (`final_reply`, `progress`). - `app.render.degraded_from` and `app.render.degraded_to` identify degradation transitions. -- Work Object creation emits a dedicated event so Work Object failures are distinguishable from message-post failures. +- Plugin attribution, where useful for debugging, is derived from the active skill set for the turn and does not need a dedicated rendering attribute. -Logs and spans remain governed by `specs/logging/index.md`. This spec does not add new behavior contracts backed by log assertions. +Logs and spans remain governed by `./logging/index.md`. This spec does not add new behavior contracts backed by log assertions. ## Verification Required verification coverage for this contract: -1. Unit: core render-intent to Slack view-model translation for each intent kind, including fallback text derivation. -2. Unit: degradation paths (unknown intent, oversized payload, Work Object failure) produce the expected lower-fidelity intent. -3. Unit: plugin renderer registry scoring, tie-breaking by registration order, and activation gating behind loaded skills. +1. Unit: core view-model translation for each intent kind, including fallback text derivation. +2. Unit: validation of intent payload schemas. Invalid payloads degrade to `plain_reply`. +3. Unit: degradation paths (unknown intent, oversized payload, renderer throws) produce the expected lower-fidelity intent with preserved fallback text. 4. Integration: final reply lane produces one hero intent per message and always includes top-level fallback text through the shared outbound boundary. 5. Integration: progress-lane writes remain best effort and do not block or fail the turn. -6. Integration: Work Object references degrade to `summary_card` when creation fails and preserve fallback text. -7. Evals: realistic Slack conversations confirm the model selects the correct intent for alerts, single-record answers, small comparisons, small result sets, and long-running tool work. +6. Evals: realistic Slack conversations confirm the model selects the correct intent for alerts, single-record answers, small comparisons, small result sets, and long-running tool work. ## Related Specs From b7fece822edde022fc8b415a62e718392a338a01 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:43:31 +0000 Subject: [PATCH 03/10] feat(slack): add render-intent palette and block renderer Introduce the closed, core-owned set of Slack render intents defined in specs/slack-rendering-spec.md: - plain_reply (pass-through) - summary_card - alert - comparison_table - result_carousel - progress_plan Each intent is validated by a zod discriminated-union schema. The renderer translates an intent into Slack Block Kit blocks plus a non-empty top-level fallback text derived from the same structured fields, which satisfies the outbound-contract requirement that every block-bearing message carry a non-empty top-level `text`. Wiring these intents into the turn-end path (so the model can emit an intent instead of raw text) is a follow-up change in the same track. Co-Authored-By: Devin Co-Authored-By: David Cramer --- .../junior/src/chat/slack/render/blocks.ts | 62 ++++ .../junior/src/chat/slack/render/intents.ts | 102 ++++++ .../junior/src/chat/slack/render/renderer.ts | 300 ++++++++++++++++++ .../tests/unit/slack/render-intents.test.ts | 262 +++++++++++++++ 4 files changed, 726 insertions(+) create mode 100644 packages/junior/src/chat/slack/render/blocks.ts create mode 100644 packages/junior/src/chat/slack/render/intents.ts create mode 100644 packages/junior/src/chat/slack/render/renderer.ts create mode 100644 packages/junior/tests/unit/slack/render-intents.test.ts diff --git a/packages/junior/src/chat/slack/render/blocks.ts b/packages/junior/src/chat/slack/render/blocks.ts new file mode 100644 index 00000000..51b1db13 --- /dev/null +++ b/packages/junior/src/chat/slack/render/blocks.ts @@ -0,0 +1,62 @@ +/** + * Slack Block Kit types used by the render-intent layer and the outbound + * boundary. This is a local subset of the Slack API surface — just the + * fields the repository actually emits. + */ + +export interface SlackMrkdwnText { + text: string; + type: "mrkdwn"; +} + +export interface SlackPlainText { + emoji?: boolean; + text: string; + type: "plain_text"; +} + +export interface SlackHeaderBlock { + text: SlackPlainText; + type: "header"; +} + +export interface SlackSectionBlock { + fields?: SlackMrkdwnText[]; + text?: SlackMrkdwnText; + type: "section"; +} + +export interface SlackDividerBlock { + type: "divider"; +} + +export interface SlackContextBlock { + elements: SlackMrkdwnText[]; + type: "context"; +} + +export interface SlackLinkButtonElement { + text: SlackPlainText; + type: "button"; + url: string; +} + +export interface SlackActionsBlock { + elements: SlackLinkButtonElement[]; + type: "actions"; +} + +export type SlackMessageBlock = + | SlackActionsBlock + | SlackContextBlock + | SlackDividerBlock + | SlackHeaderBlock + | SlackSectionBlock; + +/** Escape user-provided text for safe inclusion in Slack mrkdwn fields. */ +export function escapeSlackMrkdwnText(text: string): string { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} diff --git a/packages/junior/src/chat/slack/render/intents.ts b/packages/junior/src/chat/slack/render/intents.ts new file mode 100644 index 00000000..25ad0294 --- /dev/null +++ b/packages/junior/src/chat/slack/render/intents.ts @@ -0,0 +1,102 @@ +import { z } from "zod"; + +/** + * Native Slack render intents. + * + * Intents are the closed, core-owned vocabulary the model picks from when + * it wants richer presentation than a plain mrkdwn reply. Plugins teach + * intent usage through their SKILL.md; they do not extend the palette. + * + * Adding a new intent kind is a core change. Renderers for each intent + * live alongside this file in `renderer.ts`. + */ + +const actionSchema = z.object({ + label: z.string().min(1).max(64), + url: z.string().url(), +}); + +const fieldSchema = z.object({ + label: z.string().min(1).max(64), + value: z.string().min(1).max(1024), +}); + +const plainReplySchema = z.object({ + kind: z.literal("plain_reply"), + text: z.string().min(1), +}); + +const summaryCardSchema = z.object({ + actions: z.array(actionSchema).max(5).optional(), + body: z.string().max(2000).optional(), + fields: z.array(fieldSchema).max(10).optional(), + kind: z.literal("summary_card"), + subtitle: z.string().max(200).optional(), + title: z.string().min(1).max(200), +}); + +const alertSchema = z.object({ + actions: z.array(actionSchema).max(3).optional(), + body: z.string().max(1000).optional(), + kind: z.literal("alert"), + severity: z.enum(["info", "success", "warning", "error"]), + title: z.string().min(1).max(200), +}); + +const comparisonTableSchema = z.object({ + columns: z.array(z.string().min(1).max(64)).min(2).max(6), + kind: z.literal("comparison_table"), + rows: z + .array(z.array(z.string().max(200)).min(2).max(6)) + .min(1) + .max(20), + title: z.string().max(200).optional(), +}); + +const resultCarouselItemSchema = z.object({ + body: z.string().max(500).optional(), + fields: z.array(fieldSchema).max(5).optional(), + subtitle: z.string().max(200).optional(), + title: z.string().min(1).max(200), + url: z.string().url().optional(), +}); + +const resultCarouselSchema = z.object({ + items: z.array(resultCarouselItemSchema).min(1).max(10), + kind: z.literal("result_carousel"), + title: z.string().max(200).optional(), +}); + +const progressPlanTaskSchema = z.object({ + id: z.string().min(1).max(64), + status: z.enum(["pending", "in_progress", "complete", "error"]), + title: z.string().min(1).max(200), +}); + +const progressPlanSchema = z.object({ + kind: z.literal("progress_plan"), + tasks: z.array(progressPlanTaskSchema).min(1).max(20), + title: z.string().min(1).max(200), +}); + +export const slackRenderIntentSchema = z.discriminatedUnion("kind", [ + plainReplySchema, + summaryCardSchema, + alertSchema, + comparisonTableSchema, + resultCarouselSchema, + progressPlanSchema, +]); + +export type SlackRenderIntent = z.infer; +export type SlackRenderIntentKind = SlackRenderIntent["kind"]; + +export type PlainReplyIntent = z.infer; +export type SummaryCardIntent = z.infer; +export type AlertIntent = z.infer; +export type ComparisonTableIntent = z.infer; +export type ResultCarouselIntent = z.infer; +export type ProgressPlanIntent = z.infer; + +export type SlackRenderIntentAction = z.infer; +export type SlackRenderIntentField = z.infer; diff --git a/packages/junior/src/chat/slack/render/renderer.ts b/packages/junior/src/chat/slack/render/renderer.ts new file mode 100644 index 00000000..c1b5ef4a --- /dev/null +++ b/packages/junior/src/chat/slack/render/renderer.ts @@ -0,0 +1,300 @@ +import { + escapeSlackMrkdwnText, + type SlackMessageBlock, + type SlackMrkdwnText, +} from "@/chat/slack/render/blocks"; +import type { + AlertIntent, + ComparisonTableIntent, + PlainReplyIntent, + ProgressPlanIntent, + ResultCarouselIntent, + SlackRenderIntent, + SlackRenderIntentAction, + SlackRenderIntentField, + SlackRenderIntentKind, + SummaryCardIntent, +} from "@/chat/slack/render/intents"; + +/** + * Rendered view of an intent ready for Slack delivery. + * + * `text` is always non-empty and is the fallback shown in notifications and + * by clients that do not render blocks. `blocks` is the Block Kit payload; + * when absent, the intent renders as a plain mrkdwn reply. + */ +export interface SlackIntentRender { + blocks?: SlackMessageBlock[]; + text: string; + degradedFrom?: SlackRenderIntentKind; + degradedTo?: SlackRenderIntentKind; +} + +const SEVERITY_PREFIX: Record = { + error: ":rotating_light: ", + info: ":information_source: ", + success: ":white_check_mark: ", + warning: ":warning: ", +}; + +const PROGRESS_STATUS_ICON: Record< + ProgressPlanIntent["tasks"][number]["status"], + string +> = { + complete: ":white_check_mark:", + error: ":x:", + in_progress: ":hourglass_flowing_sand:", + pending: ":black_square_button:", +}; + +/** Render a native render intent into Slack blocks + fallback text. */ +export function renderSlackIntent( + intent: SlackRenderIntent, +): SlackIntentRender { + switch (intent.kind) { + case "plain_reply": + return renderPlainReply(intent); + case "summary_card": + return renderSummaryCard(intent); + case "alert": + return renderAlert(intent); + case "comparison_table": + return renderComparisonTable(intent); + case "result_carousel": + return renderResultCarousel(intent); + case "progress_plan": + return renderProgressPlan(intent); + } +} + +function renderPlainReply(intent: PlainReplyIntent): SlackIntentRender { + return { text: intent.text }; +} + +function renderSummaryCard(intent: SummaryCardIntent): SlackIntentRender { + const titleLine = intent.subtitle + ? `*${escapeSlackMrkdwnText(intent.title)}*\n_${escapeSlackMrkdwnText(intent.subtitle)}_` + : `*${escapeSlackMrkdwnText(intent.title)}*`; + + const sectionText = intent.body + ? `${titleLine}\n\n${intent.body}` + : titleLine; + + const blocks: SlackMessageBlock[] = [ + { + text: mrkdwn(sectionText), + type: "section", + ...(intent.fields?.length ? { fields: fieldObjects(intent.fields) } : {}), + }, + ]; + + const actions = actionsBlock(intent.actions); + if (actions) { + blocks.push(actions); + } + + return { + blocks, + text: summaryCardFallbackText(intent), + }; +} + +function renderAlert(intent: AlertIntent): SlackIntentRender { + const prefix = SEVERITY_PREFIX[intent.severity]; + const titleLine = `${prefix}*${escapeSlackMrkdwnText(intent.title)}*`; + const sectionText = intent.body ? `${titleLine}\n${intent.body}` : titleLine; + + const blocks: SlackMessageBlock[] = [ + { text: mrkdwn(sectionText), type: "section" }, + ]; + + const actions = actionsBlock(intent.actions); + if (actions) { + blocks.push(actions); + } + + return { + blocks, + text: alertFallbackText(intent), + }; +} + +function renderComparisonTable( + intent: ComparisonTableIntent, +): SlackIntentRender { + const header = intent.columns + .map((col) => `*${escapeSlackMrkdwnText(col)}*`) + .join(" \u00b7 "); + const rowLines = intent.rows.map((row) => { + const cells = row.map((cell) => escapeSlackMrkdwnText(cell)); + return `\u2022 ${cells.join(" \u00b7 ")}`; + }); + const body = [header, ...rowLines].join("\n"); + const sectionText = intent.title + ? `*${escapeSlackMrkdwnText(intent.title)}*\n${body}` + : body; + + return { + blocks: [{ text: mrkdwn(sectionText), type: "section" }], + text: comparisonTableFallbackText(intent), + }; +} + +function renderResultCarousel(intent: ResultCarouselIntent): SlackIntentRender { + const blocks: SlackMessageBlock[] = []; + + if (intent.title) { + blocks.push({ + text: { emoji: true, text: intent.title, type: "plain_text" }, + type: "header", + }); + } + + for (const [index, item] of intent.items.entries()) { + if (index > 0) { + blocks.push({ type: "divider" }); + } + const titleLink = item.url + ? `<${item.url}|${escapeSlackMrkdwnText(item.title)}>` + : escapeSlackMrkdwnText(item.title); + const titleLine = item.subtitle + ? `*${titleLink}*\n_${escapeSlackMrkdwnText(item.subtitle)}_` + : `*${titleLink}*`; + const sectionText = item.body ? `${titleLine}\n${item.body}` : titleLine; + blocks.push({ + text: mrkdwn(sectionText), + type: "section", + ...(item.fields?.length ? { fields: fieldObjects(item.fields) } : {}), + }); + } + + return { + blocks, + text: resultCarouselFallbackText(intent), + }; +} + +function renderProgressPlan(intent: ProgressPlanIntent): SlackIntentRender { + const lines = intent.tasks.map( + (task) => + `${PROGRESS_STATUS_ICON[task.status]} ${escapeSlackMrkdwnText(task.title)}`, + ); + const sectionText = `*${escapeSlackMrkdwnText(intent.title)}*\n${lines.join("\n")}`; + + return { + blocks: [{ text: mrkdwn(sectionText), type: "section" }], + text: progressPlanFallbackText(intent), + }; +} + +function mrkdwn(text: string): SlackMrkdwnText { + return { text, type: "mrkdwn" }; +} + +function fieldObjects( + fields: readonly SlackRenderIntentField[], +): SlackMrkdwnText[] { + return fields.map((field) => + mrkdwn( + `*${escapeSlackMrkdwnText(field.label)}*\n${escapeSlackMrkdwnText(field.value)}`, + ), + ); +} + +function actionsBlock( + actions: readonly SlackRenderIntentAction[] | undefined, +): SlackMessageBlock | undefined { + if (!actions?.length) { + return undefined; + } + return { + elements: actions.map((action) => ({ + text: { emoji: true, text: action.label, type: "plain_text" }, + type: "button", + url: action.url, + })), + type: "actions", + }; +} + +/** + * Derive the fallback text for a rendered intent. Exported for callers that + * need the fallback without the blocks (for example, streaming previews). + */ +export function renderIntentFallbackText(intent: SlackRenderIntent): string { + switch (intent.kind) { + case "plain_reply": + return intent.text; + case "summary_card": + return summaryCardFallbackText(intent); + case "alert": + return alertFallbackText(intent); + case "comparison_table": + return comparisonTableFallbackText(intent); + case "result_carousel": + return resultCarouselFallbackText(intent); + case "progress_plan": + return progressPlanFallbackText(intent); + } +} + +function summaryCardFallbackText(intent: SummaryCardIntent): string { + const lines: string[] = [intent.title]; + if (intent.subtitle) { + lines.push(intent.subtitle); + } + if (intent.body) { + lines.push(intent.body); + } + if (intent.fields?.length) { + for (const field of intent.fields) { + lines.push(`${field.label}: ${field.value}`); + } + } + return lines.join("\n"); +} + +function alertFallbackText(intent: AlertIntent): string { + const severityLabel = intent.severity.toUpperCase(); + const lines: string[] = [`[${severityLabel}] ${intent.title}`]; + if (intent.body) { + lines.push(intent.body); + } + return lines.join("\n"); +} + +function comparisonTableFallbackText(intent: ComparisonTableIntent): string { + const header = intent.columns.join(" | "); + const rows = intent.rows.map((row) => row.join(" | ")); + const lines: string[] = []; + if (intent.title) { + lines.push(intent.title); + } + lines.push(header, ...rows); + return lines.join("\n"); +} + +function resultCarouselFallbackText(intent: ResultCarouselIntent): string { + const lines: string[] = []; + if (intent.title) { + lines.push(intent.title); + } + for (const item of intent.items) { + const header = item.subtitle + ? `- ${item.title} — ${item.subtitle}` + : `- ${item.title}`; + lines.push(header); + if (item.body) { + lines.push(` ${item.body}`); + } + } + return lines.join("\n"); +} + +function progressPlanFallbackText(intent: ProgressPlanIntent): string { + const lines = [intent.title]; + for (const task of intent.tasks) { + lines.push(`[${task.status}] ${task.title}`); + } + return lines.join("\n"); +} diff --git a/packages/junior/tests/unit/slack/render-intents.test.ts b/packages/junior/tests/unit/slack/render-intents.test.ts new file mode 100644 index 00000000..e06afd75 --- /dev/null +++ b/packages/junior/tests/unit/slack/render-intents.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; +import { + renderIntentFallbackText, + renderSlackIntent, +} from "@/chat/slack/render/renderer"; +import { + slackRenderIntentSchema, + type SlackRenderIntent, +} from "@/chat/slack/render/intents"; + +describe("slackRenderIntentSchema", () => { + it("accepts a valid plain_reply intent", () => { + const parsed = slackRenderIntentSchema.parse({ + kind: "plain_reply", + text: "hello world", + }); + expect(parsed.kind).toBe("plain_reply"); + }); + + it("accepts a valid summary_card with all optional fields", () => { + const parsed = slackRenderIntentSchema.parse({ + actions: [{ label: "Open", url: "https://example.com/pr/1" }], + body: "Refactors the thing.", + fields: [ + { label: "Author", value: "octocat" }, + { label: "Status", value: "Open" }, + ], + kind: "summary_card", + subtitle: "example/repo#1", + title: "Add feature flag", + }); + expect(parsed.kind).toBe("summary_card"); + }); + + it("rejects an unknown intent kind", () => { + expect(() => + slackRenderIntentSchema.parse({ + kind: "totally_invented", + text: "hi", + }), + ).toThrow(/Invalid (option|input|discriminator)|kind/i); + }); + + it("rejects summary_card without a title", () => { + expect(() => + slackRenderIntentSchema.parse({ + kind: "summary_card", + title: "", + }), + ).toThrow(/title|too_small|at least/i); + }); + + it("rejects summary_card with an invalid action url", () => { + expect(() => + slackRenderIntentSchema.parse({ + actions: [{ label: "Open", url: "not a url" }], + kind: "summary_card", + title: "ok", + }), + ).toThrow(/url|Invalid/i); + }); +}); + +describe("renderSlackIntent - plain_reply", () => { + it("passes through the text and emits no blocks", () => { + const render = renderSlackIntent({ + kind: "plain_reply", + text: "the quick brown fox", + }); + expect(render.blocks).toBeUndefined(); + expect(render.text).toBe("the quick brown fox"); + }); +}); + +describe("renderSlackIntent - summary_card", () => { + const intent: SlackRenderIntent = { + actions: [{ label: "Open PR", url: "https://example.com/pr/42" }], + body: "Introduces a new flag for rolling out v2.", + fields: [ + { label: "Author", value: "octocat" }, + { label: "Status", value: "Open" }, + ], + kind: "summary_card", + subtitle: "example/repo#42", + title: "Add feature flag", + }; + + it("emits a section block with title, subtitle, body, and fields", () => { + const render = renderSlackIntent(intent); + expect(render.blocks).toBeDefined(); + const [section, actions] = render.blocks!; + expect(section.type).toBe("section"); + expect("text" in section && section.text?.type).toBe("mrkdwn"); + expect("text" in section && section.text?.text).toContain( + "*Add feature flag*", + ); + expect("text" in section && section.text?.text).toContain( + "_example/repo#42_", + ); + expect("text" in section && section.text?.text).toContain( + "Introduces a new flag for rolling out v2.", + ); + expect( + "fields" in section ? section.fields?.map((f) => f.text) : undefined, + ).toEqual(["*Author*\noctocat", "*Status*\nOpen"]); + expect(actions.type).toBe("actions"); + expect("elements" in actions ? actions.elements[0] : undefined).toEqual({ + text: { emoji: true, text: "Open PR", type: "plain_text" }, + type: "button", + url: "https://example.com/pr/42", + }); + }); + + it("omits the actions block when no actions are provided", () => { + const render = renderSlackIntent({ + kind: "summary_card", + title: "No actions here", + }); + expect(render.blocks).toHaveLength(1); + expect(render.blocks?.[0].type).toBe("section"); + }); + + it("derives a non-empty fallback text covering title, subtitle, body, fields", () => { + const render = renderSlackIntent(intent); + expect(render.text).toBe( + [ + "Add feature flag", + "example/repo#42", + "Introduces a new flag for rolling out v2.", + "Author: octocat", + "Status: Open", + ].join("\n"), + ); + }); + + it("escapes <, >, and & in title, subtitle, body field labels", () => { + const render = renderSlackIntent({ + fields: [{ label: "A", value: "x&y" }], + kind: "summary_card", + subtitle: "", + title: "A & B ", + }); + const section = render.blocks?.[0]; + const sectionText = + section && "text" in section ? (section.text?.text ?? "") : ""; + expect(sectionText).toContain("A & B <c>"); + expect(sectionText).toContain("<sub>"); + const fields = section && "fields" in section ? section.fields : undefined; + expect(fields?.[0].text).toBe("*A<b>*\nx&y"); + }); +}); + +describe("renderSlackIntent - alert", () => { + it("prefixes title with severity emoji and emits section + actions", () => { + const render = renderSlackIntent({ + actions: [{ label: "Investigate", url: "https://example.com" }], + body: "Latency spiked above threshold.", + kind: "alert", + severity: "warning", + title: "High p95 latency", + }); + expect(render.blocks).toBeDefined(); + const [section] = render.blocks!; + const text = "text" in section && section.text ? section.text.text : ""; + expect(text.startsWith(":warning: ")).toBe(true); + expect(text).toContain("*High p95 latency*"); + expect(render.text.startsWith("[WARNING]")).toBe(true); + }); +}); + +describe("renderSlackIntent - comparison_table", () => { + it("renders header + rows inline and derives a | separated fallback", () => { + const render = renderSlackIntent({ + columns: ["Metric", "Before", "After"], + kind: "comparison_table", + rows: [ + ["p50", "120ms", "90ms"], + ["p95", "800ms", "400ms"], + ], + title: "Latency", + }); + expect(render.blocks).toHaveLength(1); + expect(render.text).toContain("Metric | Before | After"); + expect(render.text).toContain("p50 | 120ms | 90ms"); + }); +}); + +describe("renderSlackIntent - result_carousel", () => { + it("emits header + dividers between items, preserves url links", () => { + const render = renderSlackIntent({ + items: [ + { + subtitle: "repo#1", + title: "First", + url: "https://example.com/1", + }, + { + title: "Second", + }, + ], + kind: "result_carousel", + title: "Matching results", + }); + const blocks = render.blocks ?? []; + expect(blocks[0].type).toBe("header"); + expect(blocks.filter((b) => b.type === "divider")).toHaveLength(1); + const firstItemSection = blocks[1]; + const text = + "text" in firstItemSection && firstItemSection.text + ? firstItemSection.text.text + : ""; + expect(text).toContain(""); + }); +}); + +describe("renderSlackIntent - progress_plan", () => { + it("renders tasks with status icons and a non-empty fallback", () => { + const render = renderSlackIntent({ + kind: "progress_plan", + tasks: [ + { id: "a", status: "complete", title: "Fetch data" }, + { id: "b", status: "in_progress", title: "Render summary" }, + { id: "c", status: "pending", title: "Post to channel" }, + ], + title: "Replying to issue 42", + }); + const section = render.blocks?.[0]; + const text = + section && "text" in section && section.text ? section.text.text : ""; + expect(text).toContain(":white_check_mark: Fetch data"); + expect(text).toContain(":hourglass_flowing_sand: Render summary"); + expect(text).toContain(":black_square_button: Post to channel"); + expect(render.text).toContain("[in_progress] Render summary"); + }); +}); + +describe("renderIntentFallbackText", () => { + it("returns the same text as renderSlackIntent for every intent kind", () => { + const intents: SlackRenderIntent[] = [ + { kind: "plain_reply", text: "hi" }, + { kind: "summary_card", title: "T" }, + { kind: "alert", severity: "info", title: "T" }, + { + columns: ["a", "b"], + kind: "comparison_table", + rows: [["1", "2"]], + }, + { items: [{ title: "one" }], kind: "result_carousel" }, + { + kind: "progress_plan", + tasks: [{ id: "x", status: "pending", title: "t" }], + title: "T", + }, + ]; + for (const intent of intents) { + const fromRender = renderSlackIntent(intent).text; + const fromHelper = renderIntentFallbackText(intent); + expect(fromHelper).toBe(fromRender); + expect(fromHelper.length).toBeGreaterThan(0); + } + }); +}); From d0254ef930a51211efc17d383870361e7d8b234f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:22:10 +0000 Subject: [PATCH 04/10] feat(slack): wire reply tool through delivery path with GitHub test bed Phase 1 of the Slack rendering engine (issue #208). - Add optional 'reply' tool (Renderer pattern): one tool whose input schema is the SlackRenderIntent discriminated union. Plain text replies keep working unchanged; 'reply' is only called when the model wants a richer intent. - Thread the captured intent from the tool -> AssistantReply -> planSlackReplyPosts -> postSlackApiReplyPosts, rendering blocks + non-empty fallback text at delivery time. - Add GitHub SKILL.md guidance teaching the model when to emit a summary_card for PRs/issues, with exact field recipes. - Update the spec to document the Intent Delivery Mechanism (ToolStrategy, Renderer vs Terminator trade-off, one tool with discriminated union). - Tests: reply-tool schema + capture; planSlackReplyPosts with intent produces blocks+fallback; existing plain text path unaffected. Co-Authored-By: David Cramer --- packages/junior-github/skills/github/SKILL.md | 15 +- .../github/references/slack-render-intents.md | 109 ++++++++++ packages/junior/src/chat/respond.ts | 10 + .../junior/src/chat/services/turn-result.ts | 18 ++ packages/junior/src/chat/slack/footer.ts | 55 +++-- .../src/chat/slack/render/reply-tool.ts | 198 ++++++++++++++++++ packages/junior/src/chat/slack/reply.ts | 62 +++++- packages/junior/src/chat/tools/index.ts | 7 + packages/junior/src/chat/tools/types.ts | 7 + .../unit/slack/render-reply-planner.test.ts | 99 +++++++++ .../unit/slack/render-reply-tool.test.ts | 100 +++++++++ specs/slack-rendering-spec.md | 18 ++ 12 files changed, 665 insertions(+), 33 deletions(-) create mode 100644 packages/junior-github/skills/github/references/slack-render-intents.md create mode 100644 packages/junior/src/chat/slack/render/reply-tool.ts create mode 100644 packages/junior/tests/unit/slack/render-reply-planner.test.ts create mode 100644 packages/junior/tests/unit/slack/render-reply-tool.test.ts diff --git a/packages/junior-github/skills/github/SKILL.md b/packages/junior-github/skills/github/SKILL.md index 350b3e0f..ba509a2a 100644 --- a/packages/junior-github/skills/github/SKILL.md +++ b/packages/junior-github/skills/github/SKILL.md @@ -13,13 +13,14 @@ Issue workflows, pull request operations, and repository checkout via `gh` CLI. Load references conditionally based on the operation: -| Operation | Load | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Any operation | [references/api-surface.md](references/api-surface.md) | -| `clone`, `pull request create` | [references/common-use-cases.md](references/common-use-cases.md) | -| `source-code investigation` | [references/research-rules.md](references/research-rules.md) | -| `issue create`, `issue body rewrite` | [references/issue-examples.md](references/issue-examples.md), the matching type-specific template and type-specific rules, and [references/research-rules.md](references/research-rules.md) | -| On failure | [references/troubleshooting-workarounds.md](references/troubleshooting-workarounds.md) | +| Operation | Load | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Any operation | [references/api-surface.md](references/api-surface.md) | +| `clone`, `pull request create` | [references/common-use-cases.md](references/common-use-cases.md) | +| `source-code investigation` | [references/research-rules.md](references/research-rules.md) | +| `issue create`, `issue body rewrite` | [references/issue-examples.md](references/issue-examples.md), the matching type-specific template and type-specific rules, and [references/research-rules.md](references/research-rules.md) | +| `pull request inspection`, `pull request mutation`, `issue view` | [references/slack-render-intents.md](references/slack-render-intents.md) when Slack is the reply surface | +| On failure | [references/troubleshooting-workarounds.md](references/troubleshooting-workarounds.md) | ## Workflow diff --git a/packages/junior-github/skills/github/references/slack-render-intents.md b/packages/junior-github/skills/github/references/slack-render-intents.md new file mode 100644 index 00000000..7b8d6360 --- /dev/null +++ b/packages/junior-github/skills/github/references/slack-render-intents.md @@ -0,0 +1,109 @@ +# Slack render intents for GitHub replies + +Junior's Slack runtime accepts an optional `reply` tool that renders a +structured message. Use it only when a plain mrkdwn reply would lose +information the user needs to act on. Plain-text replies without this +tool keep working unchanged — do not wrap every response in `reply`. + +Call `reply` at most once per turn and treat it as the final step. The +model's ordinary assistant text is ignored when the call renders +successfully. + +## When to prefer `summary_card` + +Use `summary_card` when the turn returns a single GitHub entity the user +is likely to open or take an action on: + +- The result of `gh pr view`, `gh pr create`, or `gh pr diff` on a + specific pull request. +- The result of `gh issue view` or `gh issue create` on a specific issue. + +Do not use `summary_card` for: + +- Search results across multiple PRs or issues — use `result_carousel`. +- Failure or blocked states — use `alert` with the matching severity. +- Multi-line status narratives that have no clear single entity. + +## Field recipes + +### Pull request (`summary_card`) + +```json +{ + "kind": "summary_card", + "title": "PR #", + "subtitle": "/ · → ", + "fields": [ + { "label": "Status", "value": "Open | Merged | Closed | Draft" }, + { "label": "Author", "value": "" }, + { "label": "Reviewers", "value": ", " }, + { "label": "Checks", "value": " passing / failing / pending" }, + { "label": "Files changed", "value": "" } + ], + "body": "", + "actions": [ + { + "label": "View PR", + "url": "https://github.com///pull/" + } + ] +} +``` + +Guidance: + +- Populate `title` with the PR number and upstream title; do not + paraphrase the upstream title. +- Use `subtitle` for repo + branch context only. Do not restate + information already in `fields`. +- Keep `fields` to the 3–5 most load-bearing attributes. Omit any field + you cannot fill from real data; never invent values. +- Keep `body` short. Long PR descriptions belong behind the "View PR" + action, not inlined into Slack. +- Always include a `View PR` action pointing at the canonical PR URL. +- Add a second action (`View diff`, `View checks`) only when the turn + specifically produced that artifact. + +### Issue (`summary_card`) + +```json +{ + "kind": "summary_card", + "title": "#", + "subtitle": "/", + "fields": [ + { "label": "State", "value": "Open | Closed" }, + { "label": "Labels", "value": "