From dffb4701de3683ea67dd9838880fb7ced25a740b Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 28 Aug 2026 21:41:24 +0530 Subject: [PATCH 01/12] docs(slack): specify expanded card and answer modal --- ...k-expanded-card-and-answer-modal-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-28-slack-expanded-card-and-answer-modal-design.md diff --git a/docs/superpowers/specs/2026-08-28-slack-expanded-card-and-answer-modal-design.md b/docs/superpowers/specs/2026-08-28-slack-expanded-card-and-answer-modal-design.md new file mode 100644 index 0000000..8f91640 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-slack-expanded-card-and-answer-modal-design.md @@ -0,0 +1,129 @@ +# Slack Expanded Run Card and Answer Modal Design + +## Goal + +Make the Slack live run card readable without an extra click, show run activity in chronological order with clear status markers, and provide a first-class Slack modal for free-text question answers. + +## Scope + +This change extends the Slack UX work on `codex/slack-live-preview-ux` and PR #52. + +It must: + +- Replace the native `task_card` block with a Slack `container` block. +- Make the container collapsible but expanded on first render. +- Render visible activity from oldest to newest so the current activity is the final activity line. +- Use emoji status markers: `โœ…` completed, `๐Ÿ”„` active, and `โŒ` failed. +- Keep bounded activity history and duplicate-step collapsing. +- Keep option-only questions inline. +- Add a Slack modal for questions that allow free-form answers. +- Update the original Slack question message after a modal answer is accepted. + +It must not: + +- Treat a new threaded `@Codevil` message as an implicit question answer. +- Remove free-form capability from the shared question protocol or web UI. +- Add a D1 migration or a persistent Slack-question-message mapping. +- Change run execution, queueing, retry, teardown, or deployment behavior. + +## Run Card + +### Container behavior + +`renderSlackRunCard` will emit one `container` block with: + +- `is_collapsible: true` +- `default_collapsed: false` +- A revision-specific `block_id` +- A status-marked title +- A short status subtitle +- Child blocks for activity and links + +The title marker is: + +- `๐Ÿ”„` while queued or running +- `๐Ÿ’ฌ` while waiting for a question answer or plan approval +- `โœ…` when complete +- `โŒ` when failed + +The subtitle contains the current phase, waiting state, queue position, or terminal summary. The title remains the clean public request title. + +### Activity order + +Visible activity is chronological. If history was omitted, the first detail line is `โ€ฆ N earlier steps`. Visible steps follow from oldest to newest. The current activity is therefore the final activity line. + +Step markers are: + +- `โœ…` for `done` +- `๐Ÿ”„` for `active` +- `โŒ` for `error` + +Consecutive identical steps remain collapsed. The card continues to show at most `MAX_VISIBLE_STEPS` activity rows, and the hidden count includes dropped, collapsed, and windowed rows. + +### Links and terminal output + +The container includes an `Open Codevil` link and a validated pull-request link when available. Terminal summaries appear once, not duplicated in both status and details. + +## Free-Text Answer Modal + +### Question rendering + +Option-only questions continue to use the existing buttons, checkboxes, or select controls. + +When `allowFreeform` is true, the question message adds a primary `Write answer` button. This applies whether or not predefined options are also present. `Open session` remains available as a secondary control. + +The button value carries only a versioned request identifier. The Slack action payload supplies the trusted workspace, user, channel, thread, message timestamp, and short-lived `trigger_id`. + +### Opening the modal + +The Slack actions endpoint recognizes the `codevil_question_open_freeform` block action and calls `views.open` with its `trigger_id`. + +The modal contains: + +- Title: `Answer Codevil` +- The question and bounded context +- One required multiline `plain_text_input` +- Submit label: `Send answer` +- Cancel label: `Cancel` + +The modal's server-generated `private_metadata` stores a versioned JSON object containing the request ID, team ID, channel ID, thread timestamp, and original question-message timestamp. The payload stays below Slack's metadata limit and is parsed with Zod on submission. + +### Submitting the modal + +The same Slack actions endpoint recognizes `view_submission` for the Codevil answer modal. It acknowledges the submission promptly and schedules processing through the existing `waitUntil` boundary. + +Processing: + +1. Resolves the existing Slack-thread-to-session link. +2. Rejects bot/app users and records the human actor using the existing integration identity boundary. +3. Calls the orchestrator's integration question-answer method with `requestId`, trimmed `freeform`, and the actor. +4. On success, updates the exact original question message using the message timestamp from server-generated metadata and `renderAnsweredSlackQuestion`. +5. On failure, posts the existing ephemeral failure style to the submitting user. An accepted answer is never rolled back because a later Slack message update failed. + +The orchestrator integration method will accept either option indexes or free-form text while preserving all existing question validation: the question must be open, the sandbox must be connected, and the question must allow free-form input. + +### Explicit V1 routing boundary + +A normal threaded `@Codevil ...` app mention remains a new Agent Run request. It is not inspected or reinterpreted as an answer. Free-form question answers in Slack are submitted only through `Write answer` and the modal. + +## Error Handling + +- Invalid or stale modal metadata returns an unsupported/invalid interaction response without calling the orchestrator. +- A missing thread-session link produces an ephemeral error. +- A stale, already answered, non-free-form, or disconnected question uses the existing integration answer result and ephemeral feedback. +- `views.open` failures are logged and reported ephemerally to the user when possible. +- A successful answer followed by a failed `chat.update` remains successful; the failure is logged with session and message identifiers. + +## Testing + +Tests will prove: + +- The live card is a `container` with `is_collapsible: true` and `default_collapsed: false`. +- Hidden history precedes chronological visible steps and the active step is last. +- Completed, active, and failed steps use `โœ…`, `๐Ÿ”„`, and `โŒ`. +- Free-form-capable questions include `Write answer`; option-only questions do not. +- A valid open action produces the expected `views.open` payload and versioned private metadata. +- A valid modal submission reaches the existing linked session with trimmed free-form text and updates the original question message. +- Invalid metadata, bots, missing links, stale questions, and Slack API failures do not create a new Agent Run request or consume the question incorrectly. +- Existing option-answer behavior remains green. +- The complete worker test suite and TypeScript type-check remain green. From 1beead7e82b460eeee4193e93ad98ccc05ede74b Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 28 Aug 2026 22:14:01 +0530 Subject: [PATCH 02/12] docs(slack): clarify run status indicators --- ...k-expanded-card-and-answer-modal-design.md | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/specs/2026-08-28-slack-expanded-card-and-answer-modal-design.md b/docs/superpowers/specs/2026-08-28-slack-expanded-card-and-answer-modal-design.md index 8f91640..98413e8 100644 --- a/docs/superpowers/specs/2026-08-28-slack-expanded-card-and-answer-modal-design.md +++ b/docs/superpowers/specs/2026-08-28-slack-expanded-card-and-answer-modal-design.md @@ -2,7 +2,7 @@ ## Goal -Make the Slack live run card readable without an extra click, show run activity in chronological order with clear status markers, and provide a first-class Slack modal for free-text question answers. +Make the Slack live run card readable without an extra click, show run activity in chronological order with restrained and explicit status indicators, and provide a first-class Slack modal for free-text question answers. ## Scope @@ -13,7 +13,7 @@ It must: - Replace the native `task_card` block with a Slack `container` block. - Make the container collapsible but expanded on first render. - Render visible activity from oldest to newest so the current activity is the final activity line. -- Use emoji status markers: `โœ…` completed, `๐Ÿ”„` active, and `โŒ` failed. +- Use emoji-free typographic indicators with explicit status words for completed, active, and failed activity. - Keep bounded activity history and duplicate-step collapsing. - Keep option-only questions inline. - Add a Slack modal for questions that allow free-form answers. @@ -35,28 +35,23 @@ It must not: - `is_collapsible: true` - `default_collapsed: false` - A revision-specific `block_id` -- A status-marked title +- The clean public request title without a decorative emoji - A short status subtitle - Child blocks for activity and links -The title marker is: - -- `๐Ÿ”„` while queued or running -- `๐Ÿ’ฌ` while waiting for a question answer or plan approval -- `โœ…` when complete -- `โŒ` when failed - -The subtitle contains the current phase, waiting state, queue position, or terminal summary. The title remains the clean public request title. +The subtitle states the card-level condition in plain language: the current phase, waiting state, queue position, or terminal summary. The container's optional header `icon` is not used as a status marker because Slack exposes it only at the container level, not per activity row. ### Activity order -Visible activity is chronological. If history was omitted, the first detail line is `โ€ฆ N earlier steps`. Visible steps follow from oldest to newest. The current activity is therefore the final activity line. +Visible activity is chronological. If history was omitted, the first detail line is `โ€ฆ N earlier steps`. Visible steps follow from oldest to newest. The current activity is therefore the final activity line and its explicit status label is bold. + +Each activity row is structured rich text with a restrained typographic glyph, a bold status word, and the activity label: -Step markers are: +- `โœ“ Completed โ€” Reading files โ€” page.tsx` +- `โ— Running โ€” Editing code โ€” Hero.tsx` +- `ร— Failed โ€” Running tests` -- `โœ…` for `done` -- `๐Ÿ”„` for `active` -- `โŒ` for `error` +The glyphs are plain text, not emoji. The status words make state understandable without relying on glyph shape or color. A completed row is never labelled `Running`, and only the current active row is labelled `Running`. Consecutive identical steps remain collapsed. The card continues to show at most `MAX_VISIBLE_STEPS` activity rows, and the hidden count includes dropped, collapsed, and windowed rows. @@ -120,7 +115,8 @@ Tests will prove: - The live card is a `container` with `is_collapsible: true` and `default_collapsed: false`. - Hidden history precedes chronological visible steps and the active step is last. -- Completed, active, and failed steps use `โœ…`, `๐Ÿ”„`, and `โŒ`. +- Completed, active, and failed rows use the exact status words `Completed`, `Running`, and `Failed` with the restrained text glyphs `โœ“`, `โ—`, and `ร—`. +- The title contains no status emoji, and only the final visible activity row may be labelled `Running`. - Free-form-capable questions include `Write answer`; option-only questions do not. - A valid open action produces the expected `views.open` payload and versioned private metadata. - A valid modal submission reaches the existing linked session with trimmed free-form text and updates the original question message. From 6a60823a5428601c3557cc6664801e6bd58987c1 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 28 Aug 2026 22:17:33 +0530 Subject: [PATCH 03/12] docs(slack): plan expanded card and answer modal --- ...-08-28-slack-expanded-card-answer-modal.md | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-28-slack-expanded-card-answer-modal.md diff --git a/docs/superpowers/plans/2026-08-28-slack-expanded-card-answer-modal.md b/docs/superpowers/plans/2026-08-28-slack-expanded-card-answer-modal.md new file mode 100644 index 0000000..e5f15be --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-slack-expanded-card-answer-modal.md @@ -0,0 +1,383 @@ +# Slack Expanded Card and Answer Modal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the native Slack task card with a default-expanded container that clearly labels chronological activity state, and collect free-text question answers through a Slack modal. + +**Architecture:** Keep run projection unchanged and adapt only Slack rendering. Extend the existing orchestrator integration question boundary to accept validated free-form input and expose the current question copy for modal rendering. Route Slack `block_actions` and `view_submission` payloads through typed parsers and the existing thread-to-session link before opening or submitting the modal. + +**Tech Stack:** TypeScript, Cloudflare Workers and Durable Objects, Slack Block Kit/Web API, Zod, Node test runner, pnpm. + +## Global Constraints + +- The live card is one Slack `container` with `is_collapsible: true` and `default_collapsed: false`. +- The container title is the clean public request title with no decorative status emoji. +- Visible activity is oldest-to-newest; the current active activity is the final activity row. +- Activity uses exact status words `Completed`, `Running`, and `Failed` with plain-text glyphs `โœ“`, `โ—`, and `ร—`; the status word is bold. +- Only an active step is labelled `Running`; completed and failed steps cannot be labelled `Running`. +- Keep `MAX_VISIBLE_STEPS`, duplicate collapsing, hidden-step accounting, title redaction, retry, teardown, and link validation behavior. +- Option-only questions remain inline. Questions with `allowFreeform: true` add a `Write answer` button and modal. +- A normal threaded `@Codevil` message remains a new Agent Run request; it is never treated as a question answer. +- The modal uses server-generated versioned `private_metadata`; do not add a D1 migration or persistent question-message mapping. +- Modal submission updates the exact original question message after the answer is accepted. +- Do not modify `.DS_Store` or `PRODUCTION_READINESS.md`. + +--- + +### Task 1: Default-expanded container and explicit chronological activity + +**Files:** +- Modify: `packages/worker/src/integrations/slack/render.ts` +- Test: `packages/worker/test/live-run-card.test.mjs` + +**Interfaces:** +- Consumes: `ExternalRunPresentation`, `ExternalRunStep`, `MAX_VISIBLE_STEPS`, and `validPullRequestUrl`. +- Produces: `renderSlackRunCard(presentation, sessionUrl, revision)` returning one Slack `container` block with rich-text child blocks. + +- [ ] **Step 1: Write failing container and activity tests** + +Add assertions equivalent to: + +```js +const card = renderSlackRunCard(presentation, sessionUrl, 7).blocks[0]; +assert.equal(card.type, "container"); +assert.equal(card.is_collapsible, true); +assert.equal(card.default_collapsed, false); +assert.equal(card.title.text, presentation.title); +assert.doesNotMatch(card.title.text, /โœ…|๐Ÿ”„|โŒ|๐Ÿ’ฌ/u); + +const activity = activityRows(card); +assert.deepEqual(activity.map(textFromRichTextSection), [ + "โ€ฆ 2 earlier steps", + "โœ“ Completed โ€” Reading files", + "โ— Running โ€” Editing code โ€” Hero.astro", +]); +assert.deepEqual(activity.at(-1).elements[1], { + type: "text", + text: "Running", + style: { bold: true }, +}); +``` + +Also cover a failed row (`ร— Failed`) and ensure hidden-step accounting still includes dropped, collapsed, and windowed rows. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +pnpm --filter @codevil/worker run build && node --test packages/worker/test/live-run-card.test.mjs +``` + +Expected: failure because the renderer still emits `task_card`, reverses visible rows, and uses the old one-character markers. + +- [ ] **Step 3: Implement the container renderer** + +Refactor `renderDetails` to return rich-text sections. Use the following exact row structure: + +```ts +function renderStep(step: ExternalRunStep): Record { + const state = step.status === "done" + ? { glyph: "โœ“", label: "Completed" } + : step.status === "error" + ? { glyph: "ร—", label: "Failed" } + : { glyph: "โ—", label: "Running" }; + const detail = step.detail ? ` โ€” ${step.detail}` : ""; + return { + type: "rich_text_section", + elements: [ + { type: "text", text: `${state.glyph} ` }, + { type: "text", text: state.label, style: { bold: true } }, + { type: "text", text: ` โ€” ${step.label}${detail}` }, + ], + }; +} +``` + +Calculate hidden history before adding rows, put `โ€ฆ N earlier step(s)` first, then append `collapsed.steps.slice(-MAX_VISIBLE_STEPS).map(renderStep)` without reversing it. + +Emit a container shaped as: + +```ts +{ + type: "container", + block_id: `codevil_run_${presentation.runId}_${revision}`.slice(0, 255), + title: plainText(presentation.title.slice(0, 120)), + subtitle: plainText(truncate(briefStatus(presentation), 150)), + is_collapsible: true, + default_collapsed: false, + child_blocks: [activityRichText, sourceLinksRichText], +} +``` + +Build source links as rich-text `link` elements so `Open Codevil` and the optional validated pull-request URL remain clickable without action callbacks. Omit an empty activity child block. Show terminal summary only in the subtitle. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: + +```bash +pnpm --filter @codevil/worker run build && node --test packages/worker/test/live-run-card.test.mjs packages/worker/test/slack-render.test.mjs +``` + +Expected: both files pass with zero failures. + +- [ ] **Step 5: Commit Task 1** + +```bash +git add packages/worker/src/integrations/slack/render.ts packages/worker/test/live-run-card.test.mjs packages/worker/test/slack-render.test.mjs +git commit -m "feat(slack): expand and clarify live run cards" +``` + +### Task 2: Orchestrator free-form integration boundary + +**Files:** +- Modify: `packages/worker/src/orchestrator/question-answer.ts` +- Modify: `packages/worker/src/orchestrator/questions-store.ts` +- Modify: `packages/worker/src/orchestrator.ts` +- Test: `packages/worker/test/question-answer.test.mjs` + +**Interfaces:** +- Produces: `answerQuestionFromIntegration(host, { requestId, actor, optionIndexes?, freeform? })`. +- Produces: `freeformQuestionForIntegration(host, requestId)` and Durable Object RPC `freeformQuestionForIntegration({ requestId })` returning open question copy or a typed error. +- Preserves: existing option-index callers and all validation in `applyQuestionAnswer`. + +- [ ] **Step 1: Write failing integration-boundary tests** + +Add tests equivalent to: + +```js +const result = await answer(state.host, { + requestId: "question_1", + freeform: " Use a stronger, shorter headline. ", + actor: slackActor, +}); +assert.equal(result.ok, true); +assert.deepEqual(result.selectedLabels, ["Use a stronger, shorter headline."]); +assert.equal(state.sandboxMessages[0].freeform, "Use a stronger, shorter headline."); +``` + +Also prove free-form input is rejected when `allow_freeform` is false and that `freeformQuestionForIntegration` only returns open, free-form-capable questions including their question/context copy. + +- [ ] **Step 2: Run the focused test and verify RED** + +```bash +pnpm --filter @codevil/worker run build && node --test packages/worker/test/question-answer.test.mjs +``` + +Expected: failure because the integration wrapper requires `optionIndexes` and no question-copy RPC exists. + +- [ ] **Step 3: Extend typed question storage and RPCs** + +Extend `QuestionAnswerRow` with `context: string | null`, select `context`, and return it from `loadQuestionAnswerRow`. + +Use these signatures: + +```ts +export interface IntegrationQuestionAnswerInput { + requestId: string; + actor: ParticipantIdentity; + optionIndexes?: number[]; + freeform?: string; +} + +export type IntegrationFreeformQuestionResult = + | { ok: true; question: string; context?: string } + | { ok: false; status: "not_found" | "not_open" | "freeform_not_allowed"; error: string }; +``` + +Pass both optional answer fields into `applyQuestionAnswer`. `freeformQuestionForIntegration` must load by request ID, require `status === "open"` and `allowFreeform`, and omit `context` when null. + +Expose both methods from `Orchestrator` while preserving existing option-action call sites: + +```ts +answerQuestionFromIntegration(args: IntegrationQuestionAnswerInput): IntegrationQuestionAnswerResult +freeformQuestionForIntegration(args: { requestId: string }): IntegrationFreeformQuestionResult +``` + +- [ ] **Step 4: Run focused tests and verify GREEN** + +```bash +pnpm --filter @codevil/worker run build && node --test packages/worker/test/question-answer.test.mjs packages/worker/test/slack-actions.test.mjs +``` + +Expected: both files pass. + +- [ ] **Step 5: Commit Task 2** + +```bash +git add packages/worker/src/orchestrator/question-answer.ts packages/worker/src/orchestrator/questions-store.ts packages/worker/src/orchestrator.ts packages/worker/test/question-answer.test.mjs +git commit -m "feat(slack): accept integration free-form answers" +``` + +### Task 3: Modal payload rendering and typed interaction parsing + +**Files:** +- Modify: `packages/worker/src/integrations/slack/render.ts` +- Modify: `packages/worker/src/integrations/slack/actions.ts` +- Test: `packages/worker/test/slack-render.test.mjs` +- Test: `packages/worker/test/slack-actions.test.mjs` + +**Interfaces:** +- Produces: `renderSlackFreeformAnswerModal({ question, context, privateMetadata })`. +- Produces: `parseSlackFreeformOpenAction(payload)` and `parseSlackFreeformSubmission(payload)`. +- Produces: versioned modal metadata encoder/parser kept inside `actions.ts`. + +- [ ] **Step 1: Write failing rendering and parser tests** + +Prove: + +```js +const question = renderSlackNotification(questionIntent({ allowFreeform: true }), sessionUrl)[0]; +const write = question.blocks + .find((block) => block.type === "actions") + .elements.find((element) => element.action_id === "codevil_question_open_freeform"); +assert.equal(write.text.text, "Write answer"); +assert.equal(write.style, "primary"); +``` + +An option-only question must not contain this action. Add parser fixtures for one valid `block_actions` payload and one valid `view_submission` payload. Assert that malformed metadata, empty input, wrong callback IDs, and missing `trigger_id` return `null`. + +- [ ] **Step 2: Run focused tests and verify RED** + +```bash +pnpm --filter @codevil/worker run build && node --test packages/worker/test/slack-render.test.mjs packages/worker/test/slack-actions.test.mjs +``` + +Expected: failure because the button, modal, and parsers do not exist. + +- [ ] **Step 3: Implement exact modal and interaction types** + +Use these public shapes: + +```ts +export interface SlackFreeformOpenAction { + teamId: string; + userId: string; + channelId: string; + messageTs: string; + threadTs: string; + requestId: string; + triggerId: string; +} + +export interface SlackFreeformSubmission { + teamId: string; + userId: string; + channelId: string; + messageTs: string; + threadTs: string; + requestId: string; + freeform: string; +} +``` + +Versioned private metadata is JSON with keys `{ v: 1, q, t, c, th, m }`. Parse it with Zod, require the submission payload's `team.id` to equal metadata key `t`, and cap the encoded value below 3,000 characters. The modal callback ID is `codevil_question_freeform`; the input block/action IDs are `codevil_question_freeform_input` and `codevil_question_freeform_value`. + +Render one required multiline `plain_text_input`. Bound question display to 1,000 characters and context display to 1,000 characters before composing their modal sections. Add `Write answer` only when `allowFreeform` is true, using the existing versioned question action encoder for the request ID. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +```bash +pnpm --filter @codevil/worker run build && node --test packages/worker/test/slack-render.test.mjs packages/worker/test/slack-actions.test.mjs +``` + +Expected: both files pass. + +- [ ] **Step 5: Commit Task 3** + +```bash +git add packages/worker/src/integrations/slack/render.ts packages/worker/src/integrations/slack/actions.ts packages/worker/test/slack-render.test.mjs packages/worker/test/slack-actions.test.mjs +git commit -m "feat(slack): render free-form answer modal" +``` + +### Task 4: Open and submit the modal through the Slack action endpoint + +**Files:** +- Modify: `packages/worker/src/integrations/slack/actions.ts` +- Modify: `packages/worker/src/integrations/slack/routes.ts` +- Test: `packages/worker/test/slack-actions.test.mjs` +- Test: `packages/worker/test/slack-routes.test.mjs` + +**Interfaces:** +- Consumes: Task 2 orchestrator RPCs and Task 3 modal parsers/renderer. +- Produces: `processSlackFreeformOpenAction` and `processSlackFreeformSubmission`. +- Preserves: existing option actions, non-submitting `Open session`, signed request validation, and app-mention request routing. + +- [ ] **Step 1: Write failing open/submission route tests** + +Add a valid open-action test that asserts `views.open` receives the original `trigger_id`, rendered question/context, and private metadata containing the exact original message timestamp. + +Add a valid submission test that asserts: + +```js +assert.deepEqual(answerCalls, [{ + sessionId: "ses_123", + args: { + requestId: "question_1", + freeform: "Use a stronger, shorter headline.", + actor: { id: "external:slack:U123", name: "krish" }, + }, +}]); +assert.equal(updateCall.body.ts, "171951.0002"); +assert.match(JSON.stringify(updateCall.body.blocks), /Use a stronger, shorter headline\./); +``` + +Also prove the signed endpoint closes a valid modal with HTTP 200, schedules processing through `waitUntil`, and does not call `submitAgentRequest`. Add missing-link, bot-user, stale-question, `views.open` failure, and `chat.update` failure coverage. + +- [ ] **Step 2: Run focused tests and verify RED** + +```bash +pnpm --filter @codevil/worker run build && node --test packages/worker/test/slack-actions.test.mjs packages/worker/test/slack-routes.test.mjs +``` + +Expected: failure because the action route only handles option actions. + +- [ ] **Step 3: Implement open and submit processors** + +For open actions: + +1. Resolve `externalSessionLinkSelect(integrationId("slack", teamId), channelId, threadTs)`. +2. Call `freeformQuestionForIntegration({ requestId })` on that session's orchestrator. +3. Encode trusted modal metadata with the resolved Slack coordinates. +4. Call Slack `views.open` with `trigger_id` and the rendered modal. +5. Post an ephemeral failure for missing links, stale questions, or Slack API failure. + +For submissions, share the existing human actor resolution/upsert path, then call: + +```ts +answerQuestionFromIntegration({ + requestId: submission.requestId, + freeform: submission.freeform, + actor, +}); +``` + +On accepted answer, update `submission.messageTs` with `renderAnsweredSlackQuestion`. Keep the answer accepted if `chat.update` fails and log the update failure. Handle `already_answered` consistently with option actions. + +Route order after signature/JSON validation must be: non-submitting link actions, free-form open action, free-form submission, existing option action. Construct each processing promise before returning and pass it to `waitUntil` when available. Return an empty HTTP 200 for a recognized `view_submission` so Slack closes the modal. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +```bash +pnpm --filter @codevil/worker run build && node --test packages/worker/test/slack-actions.test.mjs packages/worker/test/slack-routes.test.mjs packages/worker/test/question-answer.test.mjs packages/worker/test/slack-render.test.mjs packages/worker/test/live-run-card.test.mjs +``` + +Expected: all focused Slack/question tests pass. + +- [ ] **Step 5: Run full verification** + +```bash +pnpm --filter @codevil/worker test +pnpm --filter @codevil/worker typecheck +git diff --check +``` + +Expected: 0 test failures, type-check exit 0, and no whitespace errors. + +- [ ] **Step 6: Commit Task 4** + +```bash +git add packages/worker/src/integrations/slack/actions.ts packages/worker/src/integrations/slack/routes.ts packages/worker/test/slack-actions.test.mjs packages/worker/test/slack-routes.test.mjs +git commit -m "feat(slack): handle modal question answers" +``` From 21e4bf848d4b92415d6054f85edc100096dbc97c Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 28 Aug 2026 22:22:54 +0530 Subject: [PATCH 04/12] feat(slack): expand and clarify live run cards --- .../worker/src/integrations/slack/render.ts | 85 +++++++------ packages/worker/test/live-run-card.test.mjs | 120 ++++++++++++------ packages/worker/test/slack-render.test.mjs | 21 ++- 3 files changed, 147 insertions(+), 79 deletions(-) diff --git a/packages/worker/src/integrations/slack/render.ts b/packages/worker/src/integrations/slack/render.ts index b9f9207..3126286 100644 --- a/packages/worker/src/integrations/slack/render.ts +++ b/packages/worker/src/integrations/slack/render.ts @@ -15,23 +15,19 @@ export function renderSlackRunCard( revision: number, ): SlackMessageContent { const details = renderDetails(presentation); - const sources: Array> = [ - { type: "url", url: sessionUrl, text: "Open Codevil" }, - ]; const prUrl = presentation.prUrl ? validPullRequestUrl(presentation.prUrl) : undefined; - if (prUrl) sources.push({ type: "url", url: prUrl, text: "View pull request" }); + const childBlocks: Array> = []; + if (details.length > 0) childBlocks.push(richText(details)); + childBlocks.push(renderSourceLinks(sessionUrl, prUrl)); const block = { - type: "task_card", - task_id: `codevil_${presentation.runId}`.slice(0, 255), - title: presentation.title.slice(0, 120), - status: presentation.status, + type: "container", block_id: `codevil_run_${presentation.runId}_${revision}`.slice(0, 255), - details: richText(details), - ...(presentation.status === "complete" || presentation.status === "error" - ? { output: richText([presentation.summary ?? (presentation.status === "complete" ? "Completed successfully." : "The Agent Run failed.")]) } - : {}), - sources, + title: plainText(presentation.title.slice(0, 120)), + subtitle: plainText(truncate(briefStatus(presentation), 150)), + is_collapsible: true, + default_collapsed: false, + child_blocks: childBlocks, }; return { text: `Codevil: ${presentation.title} โ€” ${briefStatus(presentation)}. Open session: ${sessionUrl}`.slice(0, MAX_EXTERNAL_TEXT_LENGTH), @@ -39,26 +35,20 @@ export function renderSlackRunCard( }; } -function renderDetails(presentation: ExternalRunPresentation): string[] { - const lines: string[] = []; - if (presentation.queuedPosition !== undefined) { - lines.push(`In queue โ€” position ${presentation.queuedPosition}`); - } else if (presentation.waitingFor) { - lines.push(presentation.waitingFor === "question" - ? "Waiting for your answer" - : "Waiting for plan approval"); - } else { - lines.push(presentation.phase); - if (presentation.status === "in_progress" && presentation.summary) lines.push(presentation.summary); - } - +function renderDetails(presentation: ExternalRunPresentation): Array> { const collapsed = collapseConsecutiveSteps(presentation.steps); - lines.push(...collapsed.steps.slice(-MAX_VISIBLE_STEPS).reverse().map(renderStep)); const hidden = presentation.droppedSteps + collapsed.collapsedCount + Math.max(0, collapsed.steps.length - MAX_VISIBLE_STEPS); - if (hidden > 0) lines.push(`${hidden} earlier step${hidden === 1 ? "" : "s"}`); - return lines; + const sections: Array> = []; + if (hidden > 0) { + sections.push({ + type: "rich_text_section", + elements: [{ type: "text", text: `โ€ฆ ${hidden} earlier step${hidden === 1 ? "" : "s"}` }], + }); + } + sections.push(...collapsed.steps.slice(-MAX_VISIBLE_STEPS).map(renderStep)); + return sections; } function collapseConsecutiveSteps(steps: ExternalRunStep[]): { @@ -79,10 +69,21 @@ function collapseConsecutiveSteps(steps: ExternalRunStep[]): { return { steps: collapsed, collapsedCount }; } -function renderStep(step: ExternalRunStep): string { - const marker = step.status === "done" ? "โœ“" : step.status === "error" ? "โœ—" : "โ—"; +function renderStep(step: ExternalRunStep): Record { + const state = step.status === "done" + ? { glyph: "โœ“", label: "Completed" } + : step.status === "error" + ? { glyph: "ร—", label: "Failed" } + : { glyph: "โ—", label: "Running" }; const detail = step.detail ? ` โ€” ${step.detail}` : ""; - return `${marker} ${step.label}${detail}`; + return { + type: "rich_text_section", + elements: [ + { type: "text", text: `${state.glyph} ` }, + { type: "text", text: state.label, style: { bold: true } }, + { type: "text", text: ` โ€” ${step.label}${detail}` }, + ], + }; } function briefStatus(presentation: ExternalRunPresentation): string { @@ -91,16 +92,26 @@ function briefStatus(presentation: ExternalRunPresentation): string { return presentation.summary ?? presentation.status; } -function richText(lines: string[]): Record { +function richText(elements: Array>): Record { return { type: "rich_text", - elements: lines.map((text) => ({ - type: "rich_text_section", - elements: [{ type: "text", text }], - })), + elements, }; } +function renderSourceLinks(sessionUrl: string, prUrl: string | undefined): Record { + const elements: Array> = [ + { type: "link", url: sessionUrl, text: "Open Codevil" }, + ]; + if (prUrl) { + elements.push( + { type: "text", text: " ยท " }, + { type: "link", url: prUrl, text: "View pull request" }, + ); + } + return richText([{ type: "rich_text_section", elements }]); +} + export function renderSlackNotification( intent: ExternalNotificationIntent, sessionUrl: string, diff --git a/packages/worker/test/live-run-card.test.mjs b/packages/worker/test/live-run-card.test.mjs index 07fbbec..cbcc203 100644 --- a/packages/worker/test/live-run-card.test.mjs +++ b/packages/worker/test/live-run-card.test.mjs @@ -18,9 +18,18 @@ const started = { function detailsLines(presentation, sessionUrl = "https://app.codevil.example/sessions/ses_1") { const rendered = renderSlackRunCard(presentation, sessionUrl, 1); - return (rendered.blocks[0].details?.elements ?? []) - .flatMap((section) => section?.elements ?? []) - .map((element) => element.text); + return activityRows(rendered.blocks[0]).map(textFromRichTextSection); +} + +function activityRows(card) { + const activity = card.child_blocks?.find((block) => + block.type === "rich_text" && block.elements?.some((section) => + /^[โ€ฆโœ“ร—โ—]/u.test(textFromRichTextSection(section)))); + return activity?.elements ?? []; +} + +function textFromRichTextSection(section) { + return (section.elements ?? []).map((element) => element.text ?? "").join(""); } test("keeps the clean request title when the run starts with an enriched prompt", () => { @@ -55,7 +64,7 @@ test("preserves a bounded clean started-only title", () => { assert.equal(presentation.title, "Fix authentication and add tests"); }); -test("renders quiet activity rows with collapsed duplicates and bounded visibility", () => { +test("renders a default-expanded card with chronological, explicit activity rows", () => { const presentation = projectExternalRunEvents([ { cursor: 1, event: { type: "agent_request", run_id: "run_1", actor: { id: "U1", name: "Ada" }, text: "Improve the landing page", created_at: "2026-08-28T00:00:00.000Z" } }, ...[2, 3, 4].map((cursor) => ({ @@ -65,24 +74,49 @@ test("renders quiet activity rows with collapsed duplicates and bounded visibili { cursor: 5, event: { type: "agent_event", event: { type: "tool_execution_start", tool: "edit", toolCallId: "edit_1", args: { file_path: "src/Hero.astro" } } } }, ]); - const rendered = renderSlackRunCard(presentation, "https://app.codevil.example/sessions/ses_1", 1); - const expectedLines = [ - "Changing code", - "โ— Editing code โ€” Hero.astro", - "โœ“ Reading files", - "2 earlier steps", - ]; - assert.deepEqual(detailsLines(presentation).slice(0, 4), expectedLines); - assert.deepEqual( - rendered.blocks[0].details.elements, - detailsLines(presentation).map((text) => ({ - type: "rich_text_section", - elements: [{ type: "text", text }], - })), - ); + const card = renderSlackRunCard(presentation, "https://app.codevil.example/sessions/ses_1", 7).blocks[0]; + assert.equal(card.type, "container"); + assert.equal(card.is_collapsible, true); + assert.equal(card.default_collapsed, false); + assert.equal(card.title.text, presentation.title); + assert.doesNotMatch(card.title.text, /โœ…|๐Ÿ”„|โŒ|๐Ÿ’ฌ/u); + + const activity = activityRows(card); + assert.deepEqual(activity.map(textFromRichTextSection), [ + "โ€ฆ 2 earlier steps", + "โœ“ Completed โ€” Reading files", + "โ— Running โ€” Editing code โ€” Hero.astro", + ]); + assert.deepEqual(activity.at(-1).elements[1], { + type: "text", + text: "Running", + style: { bold: true }, + }); assert.equal(detailsLines(presentation).filter((line) => /^(?:โ—|โœ“|โœ—) /.test(line)).length, 2); }); +test("renders failed rows and counts dropped, collapsed, and windowed steps", () => { + const presentation = { + ...createExternalRunPresentation("run_1", "Ship the fix"), + steps: [ + { id: "old-1", label: "Reading files", status: "done", rank: 1 }, + { id: "old-2", label: "Reading files", status: "done", rank: 2 }, + { id: "middle", label: "Searching code", status: "error", rank: 3 }, + { id: "visible-1", label: "Editing code", status: "done", rank: 4 }, + { id: "visible-2", label: "Running checks", status: "error", rank: 5 }, + { id: "visible-3", label: "Publishing changes", status: "active", rank: 6 }, + ], + droppedSteps: 2, + }; + + assert.deepEqual(detailsLines(presentation), [ + "โ€ฆ 5 earlier steps", + "โœ“ Completed โ€” Editing code", + "ร— Failed โ€” Running checks", + "โ— Running โ€” Publishing changes", + ]); +}); + test("projects supported lifecycle events into a redacted, granular step list", () => { const presentation = projectExternalRunEvents([ { cursor: 1, event: started }, @@ -158,10 +192,10 @@ test("windows steps to the current step plus a few older ones on the card", () = const lines = detailsLines(presentation); const stepLines = lines.filter((line) => line.startsWith("โœ“")); assert.equal(stepLines.length, 1); - assert.deepEqual(stepLines.map((line) => line.split(" ")[1]), [ - "Reading", + assert.deepEqual(stepLines.map((line) => line.split(" โ€” ")[1]), [ + "Reading files", ]); - assert.ok(lines.some((line) => /11 earlier steps/.test(line))); + assert.ok(lines.some((line) => /โ€ฆ 11 earlier steps/.test(line))); }); test("shows a queued run with its queue position until it starts", () => { @@ -171,8 +205,7 @@ test("shows a queued run with its queue position until it starts", () => { ]); assert.equal(queued.queuedPosition, 2); assert.equal(queued.phase, "Queued"); - const queuedLines = detailsLines(queued); - assert.ok(queuedLines.some((line) => /In queue โ€” position 2/.test(line))); + assert.equal(renderSlackRunCard(queued, "https://app.codevil.example/sessions/ses_1", 1).blocks[0].subtitle.text, "In queue (position 2)"); const running = projectExternalRunEvents([ queued && { cursor: 3, event: started }, @@ -188,13 +221,13 @@ test("projects waiting, terminal, and deterministic completion states", () => { ]); assert.equal(waiting.waitingFor, "question"); assert.equal(waiting.phase, "Waiting for input"); - assert.deepEqual(detailsLines(waiting), ["Waiting for your answer"]); + assert.equal(renderSlackRunCard(waiting, "https://app.codevil.example/sessions/ses_1", 1).blocks[0].subtitle.text, "Waiting for input"); const approval = projectExternalRunEvents([ { cursor: 1, event: started }, { cursor: 2, event: { type: "approval_requested", run_id: "run_1", plan: "Update the header." } }, ]); - assert.deepEqual(detailsLines(approval), ["Waiting for plan approval"]); + assert.equal(renderSlackRunCard(approval, "https://app.codevil.example/sessions/ses_1", 1).blocks[0].subtitle.text, "Waiting for approval"); const complete = projectExternalRunEvents([ { cursor: 1, event: started }, @@ -205,21 +238,25 @@ test("projects waiting, terminal, and deterministic completion states", () => { assert.equal(complete.summary, "Completed successfully."); }); -test("renders a native task card with accessible fallback and fresh block ids", () => { +test("renders a container with accessible fallback and fresh block ids", () => { const presentation = createExternalRunPresentation("run_1", "Investigate auth"); const first = renderSlackRunCard(presentation, "https://app.codevil.example/sessions/ses_1", 7); const second = renderSlackRunCard(presentation, "https://app.codevil.example/sessions/ses_1", 8); const block = first.blocks[0]; - assert.equal(block.type, "task_card"); - assert.equal(block.task_id, "codevil_run_1"); - assert.equal(block.status, "in_progress"); + assert.equal(block.type, "container"); + assert.equal(block.title.text, "Investigate auth"); + assert.equal(block.subtitle.text, "Starting"); + assert.equal(block.is_collapsible, true); + assert.equal(block.default_collapsed, false); assert.notEqual(first.blocks[0].block_id, second.blocks[0].block_id); - assert.deepEqual(block.sources, [{ type: "url", url: "https://app.codevil.example/sessions/ses_1", text: "Open Codevil" }]); + assert.deepEqual(block.child_blocks.at(-1).elements[0].elements, [ + { type: "link", url: "https://app.codevil.example/sessions/ses_1", text: "Open Codevil" }, + ]); assert.match(first.text, /Investigate auth/); }); -test("keeps the terminal summary in output instead of repeating it in details", () => { +test("keeps the terminal summary in the subtitle instead of repeating it in activity", () => { const presentation = { ...createExternalRunPresentation("run_1", "Ship"), status: "complete", @@ -228,16 +265,17 @@ test("keeps the terminal summary in output instead of repeating it in details", }; const rendered = renderSlackRunCard(presentation, "https://app.codevil.example/sessions/ses_1", 1); - assert.doesNotMatch(JSON.stringify(rendered.blocks[0].details), /Completed successfully\./); - assert.match(JSON.stringify(rendered.blocks[0].output), /Completed successfully\./); + assert.doesNotMatch(JSON.stringify(rendered.blocks[0].child_blocks), /Completed successfully\./); + assert.equal(rendered.blocks[0].subtitle.text, "Completed successfully."); }); test("renders only a validated pull-request source", () => { const presentation = { ...createExternalRunPresentation("run_1", "Ship"), status: "complete", summary: "Completed successfully.", steps: [], droppedSteps: 0, prUrl: "https://github.com/acme/repo/pull/12" }; const rendered = renderSlackRunCard(presentation, "https://app.codevil.example/sessions/ses_1", 1); - assert.deepEqual(rendered.blocks[0].sources, [ - { type: "url", url: "https://app.codevil.example/sessions/ses_1", text: "Open Codevil" }, - { type: "url", url: "https://github.com/acme/repo/pull/12", text: "View pull request" }, + assert.deepEqual(rendered.blocks[0].child_blocks.at(-1).elements[0].elements, [ + { type: "link", url: "https://app.codevil.example/sessions/ses_1", text: "Open Codevil" }, + { type: "text", text: " ยท " }, + { type: "link", url: "https://github.com/acme/repo/pull/12", text: "View pull request" }, ]); }); @@ -253,7 +291,7 @@ test("posts a card immediately for a new request, even before the run starts", a appendEvent(sql, 2, { type: "agent_request_queued", run_id: "run_1", position: 2 }); await coordinator.onEvent(2, { type: "agent_request_queued", run_id: "run_1", position: 2 }); assert.deepEqual(calls.map((call) => call.method), ["chat.postMessage", "chat.update"]); - assert.match(JSON.stringify(calls[1].body), /In queue โ€” position 2/); + assert.match(JSON.stringify(calls[1].body), /In queue \(position 2\)/); }); test("coalesces live updates, then delivers the final response and deletes the card", async () => { @@ -277,7 +315,7 @@ test("coalesces live updates, then delivers the final response and deletes the c const methods = calls.map((call) => call.method); assert.deepEqual(methods, ["chat.postMessage", "chat.update", "chat.update", "chat.postMessage", "chat.delete"]); const terminalUpdate = calls[2]; - assert.equal(terminalUpdate.body.blocks[0].status, "complete"); + assert.equal(terminalUpdate.body.blocks[0].subtitle.text, "Completed successfully."); assert.equal(calls[3].body.text, "Done"); assert.equal(calls[4].method, "chat.delete"); assert.equal(sql.getRow("run_1"), undefined); @@ -293,7 +331,7 @@ test("deletes the card on failure after sending the failure notice", async () => await coordinator.onEvent(2, { type: "agent_run_failed", run_id: "run_1", message: "Tests failed" }); assert.deepEqual(calls.map((call) => call.method), ["chat.postMessage", "chat.update", "chat.postMessage", "chat.delete"]); - assert.equal(calls[1].body.blocks[0].status, "error"); + assert.equal(calls[1].body.blocks[0].subtitle.text, "Verification failed."); assert.match(calls[2].body.text, /could not complete the Agent Run/); assert.equal(sql.getRow("run_1"), undefined); }); @@ -546,7 +584,7 @@ test("never folds another run's live progress into a queued card", async () => { const run2Updates = calls.filter((call) => call.method === "chat.update" && JSON.stringify(call.body).includes("Second task")); const run2Card = run2Updates.at(-1); assert.ok(run2Card, "run 2 should have a queued card update"); - assert.match(JSON.stringify(run2Card.body), /In queue โ€” position 1/); + assert.match(JSON.stringify(run2Card.body), /In queue \(position 1\)/); assert.doesNotMatch(JSON.stringify(run2Card.body), /Verifying|Investigating|Reading files|lib\.ts/); }); diff --git a/packages/worker/test/slack-render.test.mjs b/packages/worker/test/slack-render.test.mjs index de0d4b8..8615929 100644 --- a/packages/worker/test/slack-render.test.mjs +++ b/packages/worker/test/slack-render.test.mjs @@ -3,10 +3,29 @@ import test from "node:test"; import * as slackRender from "../dist/integrations/slack/render.js"; -const { renderSlackNotification } = slackRender; +const { renderSlackNotification, renderSlackRunCard } = slackRender; const sessionUrl = "https://codevil.example/sessions/ses_123"; +test("renderSlackRunCard keeps validated sources clickable in rich text", () => { + const rendered = renderSlackRunCard({ + runId: "run_1", + title: "Ship", + status: "complete", + phase: "Complete", + summary: "Completed successfully.", + steps: [], + droppedSteps: 0, + prUrl: "https://github.com/acme/app/pull/12", + }, sessionUrl, 1); + + assert.deepEqual(rendered.blocks[0].child_blocks.at(-1).elements[0].elements, [ + { type: "link", url: sessionUrl, text: "Open Codevil" }, + { type: "text", text: " ยท " }, + { type: "link", url: "https://github.com/acme/app/pull/12", text: "View pull request" }, + ]); +}); + test("renderSlackNotification renders conversational messages", () => { assert.deepEqual( renderSlackNotification({ From b0cdaab0b004fb65b6f819bb7a48144ee6415851 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 28 Aug 2026 22:28:57 +0530 Subject: [PATCH 05/12] fix(slack): clarify waiting run subtitles --- packages/worker/src/integrations/slack/render.ts | 2 ++ packages/worker/test/live-run-card.test.mjs | 11 ++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/integrations/slack/render.ts b/packages/worker/src/integrations/slack/render.ts index 3126286..4d3af0b 100644 --- a/packages/worker/src/integrations/slack/render.ts +++ b/packages/worker/src/integrations/slack/render.ts @@ -88,6 +88,8 @@ function renderStep(step: ExternalRunStep): Record { function briefStatus(presentation: ExternalRunPresentation): string { if (presentation.queuedPosition !== undefined) return `In queue (position ${presentation.queuedPosition})`; + if (presentation.waitingFor === "question") return "Waiting for your answer"; + if (presentation.waitingFor === "approval") return "Waiting for plan approval"; if (presentation.status === "in_progress") return presentation.phase; return presentation.summary ?? presentation.status; } diff --git a/packages/worker/test/live-run-card.test.mjs b/packages/worker/test/live-run-card.test.mjs index cbcc203..8d52cba 100644 --- a/packages/worker/test/live-run-card.test.mjs +++ b/packages/worker/test/live-run-card.test.mjs @@ -214,21 +214,26 @@ test("shows a queued run with its queue position until it starts", () => { assert.equal(running.status, "in_progress"); }); -test("projects waiting, terminal, and deterministic completion states", () => { +test("renders an explicit question waiting subtitle", () => { const waiting = projectExternalRunEvents([ { cursor: 1, event: started }, { cursor: 2, event: { type: "question_raised", request_id: "q1", run_id: "run_1", question: "Which region?", allow_freeform: false, allow_multiple: false, answerable_by: "anyone", status: "open", raised_at: "2026-08-13T00:00:00.000Z" } }, ]); assert.equal(waiting.waitingFor, "question"); assert.equal(waiting.phase, "Waiting for input"); - assert.equal(renderSlackRunCard(waiting, "https://app.codevil.example/sessions/ses_1", 1).blocks[0].subtitle.text, "Waiting for input"); + assert.equal(renderSlackRunCard(waiting, "https://app.codevil.example/sessions/ses_1", 1).blocks[0].subtitle.text, "Waiting for your answer"); +}); +test("renders an explicit plan approval waiting subtitle", () => { const approval = projectExternalRunEvents([ { cursor: 1, event: started }, { cursor: 2, event: { type: "approval_requested", run_id: "run_1", plan: "Update the header." } }, ]); - assert.equal(renderSlackRunCard(approval, "https://app.codevil.example/sessions/ses_1", 1).blocks[0].subtitle.text, "Waiting for approval"); + assert.equal(approval.waitingFor, "approval"); + assert.equal(renderSlackRunCard(approval, "https://app.codevil.example/sessions/ses_1", 1).blocks[0].subtitle.text, "Waiting for plan approval"); +}); +test("projects terminal and deterministic completion states", () => { const complete = projectExternalRunEvents([ { cursor: 1, event: started }, { cursor: 2, event: { type: "agent_run_completed", run_id: "run_1", pr_url: "https://github.com/acme/repo/pull/12" } }, From bbb037d51beb0cee07da9404fbad4f8083949790 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 28 Aug 2026 22:34:58 +0530 Subject: [PATCH 06/12] feat(slack): accept integration free-form answers --- packages/worker/src/orchestrator.ts | 17 +++-- .../src/orchestrator/question-answer.ts | 41 ++++++++-- .../src/orchestrator/questions-store.ts | 11 ++- packages/worker/test/question-answer.test.mjs | 75 +++++++++++++++++++ 4 files changed, 130 insertions(+), 14 deletions(-) diff --git a/packages/worker/src/orchestrator.ts b/packages/worker/src/orchestrator.ts index db5e7aa..5b267a0 100644 --- a/packages/worker/src/orchestrator.ts +++ b/packages/worker/src/orchestrator.ts @@ -116,6 +116,9 @@ import { } from "./session-directory.js"; import { answerQuestionFromIntegration as answerQuestionFromIntegrationFn, + freeformQuestionForIntegration as freeformQuestionForIntegrationFn, + type IntegrationFreeformQuestionResult, + type IntegrationQuestionAnswerInput, type IntegrationQuestionAnswerResult, } from "./orchestrator/question-answer.js"; import { workerLogForSession, workerLogSessionExceptionForEnv } from "./logging.js"; @@ -1128,17 +1131,21 @@ export class Orchestrator extends DurableObject implements OrchestratorHost return { ok: true }; } - answerQuestionFromIntegration(args: { - requestId: string; - optionIndexes: number[]; - actor: ParticipantIdentity; - }): IntegrationQuestionAnswerResult { + answerQuestionFromIntegration(args: IntegrationQuestionAnswerInput): IntegrationQuestionAnswerResult { this.loadMeta(); if (!this.meta) { return { ok: false, status: "not_open", error: "Session not initialized" }; } return answerQuestionFromIntegrationFn(this, args); } + + freeformQuestionForIntegration(args: { requestId: string }): IntegrationFreeformQuestionResult { + this.loadMeta(); + if (!this.meta) { + return { ok: false, status: "not_open", error: "Session not initialized" }; + } + return freeformQuestionForIntegrationFn(this, args.requestId); + } } function sandboxSessionIdFromPath(pathname: string): string | null { diff --git a/packages/worker/src/orchestrator/question-answer.ts b/packages/worker/src/orchestrator/question-answer.ts index 22da6ef..008777b 100644 --- a/packages/worker/src/orchestrator/question-answer.ts +++ b/packages/worker/src/orchestrator/question-answer.ts @@ -16,6 +16,17 @@ export type IntegrationQuestionAnswerResult = error: string; }; +export interface IntegrationQuestionAnswerInput { + requestId: string; + actor: ParticipantIdentity; + optionIndexes?: number[]; + freeform?: string; +} + +export type IntegrationFreeformQuestionResult = + | { ok: true; question: string; context?: string } + | { ok: false; status: "not_found" | "not_open" | "freeform_not_allowed"; error: string }; + interface AnswerQuestionInput { requestId: string; actor: ParticipantIdentity; @@ -26,21 +37,41 @@ interface AnswerQuestionInput { export function answerQuestionFromIntegration( host: OrchestratorHost, - args: { - requestId: string; - optionIndexes: number[]; - actor: ParticipantIdentity; - }, + args: IntegrationQuestionAnswerInput, ): IntegrationQuestionAnswerResult { // Slack conversation membership is the authorization boundary for this path. // Web-only answerable_by policies are intentionally enforced by handleQuestionAnswer instead. return applyQuestionAnswer(host, { requestId: args.requestId, optionIndexes: args.optionIndexes, + freeform: args.freeform, actor: args.actor, }); } +export function freeformQuestionForIntegration( + host: OrchestratorHost, + requestId: string, +): IntegrationFreeformQuestionResult { + const question = loadQuestionAnswerRow(host.sql, requestId); + if (!question) return { ok: false, status: "not_found", error: "Question not found" }; + if (question.status !== "open") { + return { ok: false, status: "not_open", error: "Question is no longer open" }; + } + if (!question.allowFreeform) { + return { + ok: false, + status: "freeform_not_allowed", + error: "Question does not accept free-form input", + }; + } + return { + ok: true, + question: question.question, + ...(question.context !== null ? { context: question.context } : {}), + }; +} + export function applyQuestionAnswer( host: OrchestratorHost, input: AnswerQuestionInput, diff --git a/packages/worker/src/orchestrator/questions-store.ts b/packages/worker/src/orchestrator/questions-store.ts index 30ef90d..df44935 100644 --- a/packages/worker/src/orchestrator/questions-store.ts +++ b/packages/worker/src/orchestrator/questions-store.ts @@ -32,7 +32,7 @@ export function loadQuestionRow(sql: SqlStorage, requestId: string): QuestionRow )) { return parseSqliteRow( QuestionRowSchema, - row as Record, + row, "sqlite_question", ); } @@ -42,6 +42,7 @@ export function loadQuestionRow(sql: SqlStorage, requestId: string): QuestionRow const QuestionAnswerDbRowSchema = z.object({ request_id: z.string(), question: z.string(), + context: z.string().nullable(), status: z.string(), options_json: z.string().nullable(), allow_freeform: z.number().int(), @@ -65,6 +66,7 @@ const StoredAnswerSchema = z.object({ export interface QuestionAnswerRow { requestId: string; question: string; + context: string | null; status: string; options: Array>; allowFreeform: boolean; @@ -75,13 +77,13 @@ export interface QuestionAnswerRow { export function loadQuestionAnswerRow(sql: SqlStorage, requestId: string): QuestionAnswerRow | null { for (const row of sql.exec( - `SELECT request_id, question, status, options_json, allow_freeform, allow_multiple, + `SELECT request_id, question, context, status, options_json, allow_freeform, allow_multiple, answer_json, answered_by_id, answered_by_name FROM questions WHERE request_id = ?`, requestId, )) { - const result = QuestionAnswerDbRowSchema.safeParse(row as Record); + const result = QuestionAnswerDbRowSchema.safeParse(row); if (!result.success) return null; const parsed = result.data; const options = parseStoredJson(parsed.options_json, z.array(StoredQuestionOptionSchema), []); @@ -92,6 +94,7 @@ export function loadQuestionAnswerRow(sql: SqlStorage, requestId: string): Quest return { requestId: parsed.request_id, question: parsed.question, + context: parsed.context, status: parsed.status, options, allowFreeform: parsed.allow_freeform === 1, @@ -121,7 +124,7 @@ export function listOpenQuestionIds(sql: SqlStorage, runId: string): string[] { )) { const parsed = parseSqliteRow( RequestIdRowSchema, - row as Record, + row, "sqlite_question_id", ); if (parsed) ids.push(parsed.request_id); diff --git a/packages/worker/test/question-answer.test.mjs b/packages/worker/test/question-answer.test.mjs index 8f38a55..6fa88b2 100644 --- a/packages/worker/test/question-answer.test.mjs +++ b/packages/worker/test/question-answer.test.mjs @@ -9,11 +9,18 @@ async function answer(host, args) { return module.answerQuestionFromIntegration(host, args); } +async function freeformQuestion(host, requestId) { + const module = await questionAnswerModule; + assert.equal(typeof module.freeformQuestionForIntegration, "function"); + return module.freeformQuestionForIntegration(host, requestId); +} + function question(overrides = {}) { return { request_id: "question_1", run_id: "run_1", question: "Which database?", + context: null, status: "open", options_json: JSON.stringify([ { id: "pg", label: "PostgreSQL" }, @@ -96,6 +103,74 @@ test("Slack option ordinals map to stored option IDs", async () => { }]); }); +test("integration answers a free-form question with trimmed text", async () => { + const state = fixture(question({ + question: "What should change?", + allow_freeform: 1, + context: "The headline needs another pass.", + })); + const result = await answer(state.host, { + requestId: "question_1", + freeform: " Use a stronger, shorter headline. ", + actor: slackActor, + }); + + assert.deepEqual(result, { + ok: true, + status: "answered", + question: "What should change?", + selectedLabels: ["Use a stronger, shorter headline."], + answeredBy: slackActor, + }); + assert.equal(state.sandboxMessages[0].freeform, "Use a stronger, shorter headline."); + assert.deepEqual(state.sandboxMessages[0].option_ids, []); +}); + +test("integration rejects free-form text when the question disallows it", async () => { + const state = fixture(); + const result = await answer(state.host, { + requestId: "question_1", + freeform: " Use a stronger, shorter headline. ", + actor: slackActor, + }); + + assert.deepEqual(result, { + ok: false, + status: "invalid_selection", + error: "Question does not accept free-form input", + }); + assert.equal(state.row.status, "open"); + assert.equal(state.broadcasts.length, 0); + assert.equal(state.sandboxMessages.length, 0); +}); + +test("free-form question lookup returns only open, free-form-capable question copy", async () => { + const openState = fixture(question({ + question: "What should change?", + context: "The headline needs another pass.", + allow_freeform: 1, + })); + assert.deepEqual(await freeformQuestion(openState.host, "question_1"), { + ok: true, + question: "What should change?", + context: "The headline needs another pass.", + }); + + const noContextState = fixture(question({ allow_freeform: 1 })); + assert.deepEqual(await freeformQuestion(noContextState.host, "question_1"), { + ok: true, + question: "Which database?", + }); + + for (const [row, expected] of [ + [null, { ok: false, status: "not_found", error: "Question not found" }], + [question({ allow_freeform: 1, status: "cancelled" }), { ok: false, status: "not_open", error: "Question is no longer open" }], + [question({ allow_freeform: 0 }), { ok: false, status: "freeform_not_allowed", error: "Question does not accept free-form input" }], + ]) { + assert.deepEqual(await freeformQuestion(fixture(row).host, "question_1"), expected); + } +}); + test("Slack answer validation rejects invalid ordinals and cardinality", async () => { for (const optionIndexes of [[-1], [9], [0, 1], [1.5]]) { const state = fixture(); From 6e59865317259d567842f3e8ebf8338986b4e1e2 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 28 Aug 2026 23:15:54 +0530 Subject: [PATCH 07/12] feat(slack): render free-form answer modal --- .../worker/src/integrations/slack/actions.ts | 126 ++++++++++++++++ .../worker/src/integrations/slack/render.ts | 52 +++++++ packages/worker/test/slack-actions.test.mjs | 136 ++++++++++++++++++ packages/worker/test/slack-render.test.mjs | 48 ++++++- 4 files changed, 359 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/integrations/slack/actions.ts b/packages/worker/src/integrations/slack/actions.ts index d669e01..8458ab2 100644 --- a/packages/worker/src/integrations/slack/actions.ts +++ b/packages/worker/src/integrations/slack/actions.ts @@ -38,12 +38,45 @@ const SlackBlockActionSchema = z.object({ state: z.unknown().optional(), }); +const SlackFreeformOpenActionSchema = SlackBlockActionSchema.extend({ + trigger_id: z.string().min(1), +}); + +const SlackFreeformSubmissionSchema = z.object({ + type: z.literal("view_submission"), + trigger_id: z.string().min(1), + team: z.object({ id: z.string().min(1) }), + user: z.object({ id: z.string().min(1) }), + view: z.object({ + callback_id: z.string().min(1), + private_metadata: z.string().min(1), + state: z.object({ + values: z.record(z.string(), z.record(z.string(), z.object({ + value: z.string().nullable().optional(), + }).passthrough())), + }), + }), +}); + const QuestionActionValueSchema = z.object({ v: z.literal(1), q: z.string().min(1), i: z.number().int().nonnegative().optional(), }); +const SlackFreeformPrivateMetadataSchema = z.object({ + v: z.literal(1), + q: z.string().min(1), + t: z.string().min(1), + c: z.string().min(1), + th: z.string().min(1), + m: z.string().min(1), +}).strict(); + +const MAX_SLACK_PRIVATE_METADATA_LENGTH = 3_000; + +type SlackFreeformPrivateMetadata = z.infer; + export interface SlackQuestionAction { teamId: string; userId: string; @@ -55,11 +88,104 @@ export interface SlackQuestionAction { actionTs: string; } +export interface SlackFreeformOpenAction { + teamId: string; + userId: string; + channelId: string; + messageTs: string; + threadTs: string; + requestId: string; + triggerId: string; +} + +export interface SlackFreeformSubmission { + teamId: string; + userId: string; + channelId: string; + messageTs: string; + threadTs: string; + requestId: string; + freeform: string; +} + +export interface SlackFreeformPrivateMetadataInput { + requestId: string; + teamId: string; + channelId: string; + threadTs: string; + messageTs: string; +} + export interface SlackActionProcessDeps { slackApi?: SlackApi; workerOrigin?: string; } +export function encodeSlackFreeformPrivateMetadata( + input: SlackFreeformPrivateMetadataInput, +): string | null { + const value = JSON.stringify({ + v: 1, + q: input.requestId, + t: input.teamId, + c: input.channelId, + th: input.threadTs, + m: input.messageTs, + }); + return value.length < MAX_SLACK_PRIVATE_METADATA_LENGTH ? value : null; +} + +export function parseSlackFreeformPrivateMetadata(value: unknown): SlackFreeformPrivateMetadata | null { + if (typeof value !== "string" || value.length >= MAX_SLACK_PRIVATE_METADATA_LENGTH) return null; + let decoded: unknown; + try { + decoded = JSON.parse(value); + } catch { + return null; + } + const parsed = SlackFreeformPrivateMetadataSchema.safeParse(decoded); + return parsed.success ? parsed.data : null; +} + +export function parseSlackFreeformOpenAction(payload: unknown): SlackFreeformOpenAction | null { + const parsed = SlackFreeformOpenActionSchema.safeParse(payload); + if (!parsed.success) return null; + const action = parsed.data.actions[0]; + if (action.action_id !== "codevil_question_open_freeform") return null; + const value = parseQuestionActionValue(action.value); + if (!value || value.i !== undefined) return null; + return { + teamId: parsed.data.team.id, + userId: parsed.data.user.id, + channelId: parsed.data.channel.id, + messageTs: parsed.data.message.ts, + threadTs: parsed.data.message.thread_ts ?? parsed.data.message.ts, + requestId: value.q, + triggerId: parsed.data.trigger_id, + }; +} + +export function parseSlackFreeformSubmission(payload: unknown): SlackFreeformSubmission | null { + const parsed = SlackFreeformSubmissionSchema.safeParse(payload); + if (!parsed.success || parsed.data.view.callback_id !== "codevil_question_freeform") return null; + const metadata = parseSlackFreeformPrivateMetadata(parsed.data.view.private_metadata); + if (!metadata || parsed.data.team.id !== metadata.t) return null; + + const input = parsed.data.view.state.values.codevil_question_freeform_input + ?.codevil_question_freeform_value?.value; + if (typeof input !== "string" || input.trim().length === 0) return null; + + return { + teamId: parsed.data.team.id, + userId: parsed.data.user.id, + channelId: metadata.c, + messageTs: metadata.m, + threadTs: metadata.th, + requestId: metadata.q, + freeform: input, + }; +} + export function parseSlackQuestionAction(payload: unknown): SlackQuestionAction | null { const parsed = SlackBlockActionSchema.safeParse(payload); if (!parsed.success) return null; diff --git a/packages/worker/src/integrations/slack/render.ts b/packages/worker/src/integrations/slack/render.ts index 4d3af0b..8b5074c 100644 --- a/packages/worker/src/integrations/slack/render.ts +++ b/packages/worker/src/integrations/slack/render.ts @@ -8,6 +8,7 @@ const MAX_EXTERNAL_TEXT_LENGTH = 500; const MAX_SLACK_MARKDOWN_CHARS = 11_500; const MAX_SLACK_ACTION_VALUE_LENGTH = 2_000; const MAX_SLACK_OPTION_TEXT_LENGTH = 75; +const MAX_SLACK_MODAL_TEXT_LENGTH = 1_000; export function renderSlackRunCard( presentation: ExternalRunPresentation, @@ -136,6 +137,44 @@ export function renderSlackNotification( } } +export function renderSlackFreeformAnswerModal(input: { + question: string; + context?: string; + privateMetadata: string; +}): Record { + const blocks: Array> = [ + { + type: "section", + text: markdownText(`*Question*\n${truncate(input.question, MAX_SLACK_MODAL_TEXT_LENGTH)}`), + }, + ]; + if (input.context) { + blocks.push({ + type: "section", + text: markdownText(`*Context*\n${truncate(input.context, MAX_SLACK_MODAL_TEXT_LENGTH)}`), + }); + } + blocks.push({ + type: "input", + block_id: "codevil_question_freeform_input", + label: plainText("Answer"), + element: { + type: "plain_text_input", + action_id: "codevil_question_freeform_value", + multiline: true, + }, + }); + return { + type: "modal", + callback_id: "codevil_question_freeform", + private_metadata: input.privateMetadata, + title: plainText("Answer question"), + submit: plainText("Send"), + close: plainText("Cancel"), + blocks, + }; +} + export function encodeSlackQuestionAction(input: { requestId: string; optionIndex?: number; @@ -238,6 +277,15 @@ function questionActions( } const optionsShownInControls = elements.length > 0; + if (intent.allowFreeform && submitValue) { + elements.push({ + type: "button", + action_id: "codevil_question_open_freeform", + text: plainText("Write answer"), + style: "primary", + value: submitValue, + }); + } elements.push(openSessionButton(sessionUrl)); return { block: { @@ -286,6 +334,10 @@ function plainText(text: string): { type: "plain_text"; text: string; emoji: tru return { type: "plain_text", text, emoji: true }; } +function markdownText(text: string): { type: "mrkdwn"; text: string } { + return { type: "mrkdwn", text }; +} + function truncate(value: string, maxLength: number): string { return value.length <= maxLength ? value : value.slice(0, maxLength - 1).trimEnd() + "โ€ฆ"; } diff --git a/packages/worker/test/slack-actions.test.mjs b/packages/worker/test/slack-actions.test.mjs index 8b6f764..9288ee0 100644 --- a/packages/worker/test/slack-actions.test.mjs +++ b/packages/worker/test/slack-actions.test.mjs @@ -109,6 +109,142 @@ test("isSlackNonSubmittingAction recognizes controls that need acknowledgement o assert.equal(module.isSlackNonSubmittingAction(basePayload()), false); }); +const modalMetadata = JSON.stringify({ + v: 1, + q: "question_1", + t: "T123", + c: "C123", + th: "171951.0001", + m: "171951.0002", +}); + +function freeformOpenPayload(overrides = {}) { + return { + type: "block_actions", + trigger_id: "1337.abc", + team: { id: "T123" }, + user: { id: "U123" }, + channel: { id: "C123" }, + container: { type: "message", message_ts: "171951.0002", channel_id: "C123" }, + message: { ts: "171951.0002", thread_ts: "171951.0001" }, + actions: [{ + action_id: "codevil_question_open_freeform", + action_ts: "171951.1111", + value: JSON.stringify({ v: 1, q: "question_1" }), + }], + ...overrides, + }; +} + +function freeformSubmissionPayload(overrides = {}) { + return { + type: "view_submission", + trigger_id: "1337.def", + team: { id: "T123" }, + user: { id: "U123" }, + view: { + callback_id: "codevil_question_freeform", + private_metadata: modalMetadata, + state: { + values: { + codevil_question_freeform_input: { + codevil_question_freeform_value: { + type: "plain_text_input", + value: "Use PostgreSQL", + }, + }, + }, + }, + }, + ...overrides, + }; +} + +test("parseSlackFreeformOpenAction parses the typed modal opener", async () => { + const module = await actionsModule; + assert.equal(typeof module.parseSlackFreeformOpenAction, "function"); + assert.deepEqual(module.parseSlackFreeformOpenAction(freeformOpenPayload()), { + teamId: "T123", + userId: "U123", + channelId: "C123", + messageTs: "171951.0002", + threadTs: "171951.0001", + requestId: "question_1", + triggerId: "1337.abc", + }); +}); + +test("parseSlackFreeformSubmission parses metadata and the required answer", async () => { + const module = await actionsModule; + assert.equal(typeof module.parseSlackFreeformSubmission, "function"); + assert.deepEqual(module.parseSlackFreeformSubmission(freeformSubmissionPayload()), { + teamId: "T123", + userId: "U123", + channelId: "C123", + messageTs: "171951.0002", + threadTs: "171951.0001", + requestId: "question_1", + freeform: "Use PostgreSQL", + }); +}); + +test("free-form interaction parsers reject malformed metadata, empty input, wrong callbacks, and missing trigger IDs", async () => { + const module = await actionsModule; + const malformedMetadata = freeformSubmissionPayload({ + view: { ...freeformSubmissionPayload().view, private_metadata: "not-json" }, + }); + const emptyInput = freeformSubmissionPayload({ + view: { + ...freeformSubmissionPayload().view, + state: { values: { codevil_question_freeform_input: { + codevil_question_freeform_value: { value: " " }, + } } }, + }, + }); + + for (const payload of [ + malformedMetadata, + emptyInput, + freeformSubmissionPayload({ view: { ...freeformSubmissionPayload().view, callback_id: "wrong_callback" } }), + freeformSubmissionPayload({ team: { id: "T999" } }), + ]) { + assert.equal(module.parseSlackFreeformSubmission(payload), null); + } + + assert.equal(module.parseSlackFreeformOpenAction(freeformOpenPayload({ trigger_id: "" })), null); + assert.equal(module.parseSlackFreeformOpenAction(freeformOpenPayload({ + actions: [{ action_id: "wrong_action", action_ts: "171951.1111", value: JSON.stringify({ v: 1, q: "question_1" }) }], + })), null); +}); + +test("free-form metadata encoding is versioned and stays below Slack's limit", async () => { + const module = await actionsModule; + assert.equal(typeof module.encodeSlackFreeformPrivateMetadata, "function"); + const encoded = module.encodeSlackFreeformPrivateMetadata({ + requestId: "question_1", + teamId: "T123", + channelId: "C123", + threadTs: "171951.0001", + messageTs: "171951.0002", + }); + assert.deepEqual(JSON.parse(encoded), { + v: 1, + q: "question_1", + t: "T123", + c: "C123", + th: "171951.0001", + m: "171951.0002", + }); + assert.equal(encoded.length < 3_000, true); + assert.equal(module.encodeSlackFreeformPrivateMetadata({ + requestId: "q", + teamId: "T", + channelId: "C", + threadTs: "th", + messageTs: "m".repeat(3_000), + }), null); +}); + function parsedAction(overrides = {}) { return { teamId: "T123", diff --git a/packages/worker/test/slack-render.test.mjs b/packages/worker/test/slack-render.test.mjs index 8615929..dd43dfd 100644 --- a/packages/worker/test/slack-render.test.mjs +++ b/packages/worker/test/slack-render.test.mjs @@ -3,7 +3,11 @@ import test from "node:test"; import * as slackRender from "../dist/integrations/slack/render.js"; -const { renderSlackNotification, renderSlackRunCard } = slackRender; +const { + renderSlackFreeformAnswerModal, + renderSlackNotification, + renderSlackRunCard, +} = slackRender; const sessionUrl = "https://codevil.example/sessions/ses_123"; @@ -121,10 +125,10 @@ test("multiple-choice questions use a multi-select above ten options", () => { assert.deepEqual(actions.elements.map((element) => element.type), ["multi_static_select", "button", "button"]); }); -test("unrepresentable and free-form-only questions fall back to Open session", () => { +test("unrepresentable questions fall back to Open session", () => { for (const intent of [ questionIntent({ options: Array.from({ length: 101 }, (_, index) => ({ id: `o${index}`, label: `Option ${index}` })) }), - questionIntent({ options: undefined, allowFreeform: true }), + questionIntent({ options: undefined }), ]) { const [message] = renderSlackNotification(intent, sessionUrl); const actions = message.blocks.find((block) => block.type === "actions"); @@ -137,6 +141,44 @@ test("unrepresentable and free-form-only questions fall back to Open session", ( assert.match(unrepresentable.blocks[0].text, /Option 0/); }); +test("free-form questions show a primary Write answer action while option-only questions do not", () => { + const [freeformQuestion] = renderSlackNotification(questionIntent({ allowFreeform: true }), sessionUrl); + const freeformActions = freeformQuestion.blocks.find((block) => block.type === "actions"); + const write = freeformActions.elements.find((element) => element.action_id === "codevil_question_open_freeform"); + assert.equal(write.text.text, "Write answer"); + assert.equal(write.style, "primary"); + assert.deepEqual(JSON.parse(write.value), { v: 1, q: "question_1" }); + + const [optionOnlyQuestion] = renderSlackNotification(questionIntent(), sessionUrl); + const optionOnlyActions = optionOnlyQuestion.blocks.find((block) => block.type === "actions"); + assert.equal(optionOnlyActions.elements.some((element) => element.action_id === "codevil_question_open_freeform"), false); +}); + +test("renderSlackFreeformAnswerModal bounds display text and renders one required multiline input", () => { + const privateMetadata = JSON.stringify({ v: 1, q: "question_1", t: "T123", c: "C123", th: "171951.0001", m: "171951.0002" }); + const modal = renderSlackFreeformAnswerModal({ + question: "Q".repeat(1_050), + context: "C".repeat(1_050), + privateMetadata, + }); + + assert.equal(modal.type, "modal"); + assert.equal(modal.callback_id, "codevil_question_freeform"); + assert.equal(modal.private_metadata, privateMetadata); + assert.equal(modal.blocks[0].text.text, `*Question*\n${"Q".repeat(999)}โ€ฆ`); + assert.equal(modal.blocks[1].text.text, `*Context*\n${"C".repeat(999)}โ€ฆ`); + assert.deepEqual(modal.blocks[2], { + type: "input", + block_id: "codevil_question_freeform_input", + label: { type: "plain_text", text: "Answer", emoji: true }, + element: { + type: "plain_text_input", + action_id: "codevil_question_freeform_value", + multiline: true, + }, + }); +}); + test("answered questions remove controls and mention the Slack answerer", () => { assert.equal(typeof slackRender.renderAnsweredSlackQuestion, "function"); const message = slackRender.renderAnsweredSlackQuestion({ From accd7042d27ef89f2b864906468ca8e5c02cf39e Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Sat, 29 Aug 2026 19:10:59 +0530 Subject: [PATCH 08/12] fix(slack): align free-form modal contract --- packages/worker/src/integrations/slack/actions.ts | 6 ++++-- packages/worker/src/integrations/slack/render.ts | 4 ++-- packages/worker/test/slack-actions.test.mjs | 8 ++++++++ packages/worker/test/slack-render.test.mjs | 3 +++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/worker/src/integrations/slack/actions.ts b/packages/worker/src/integrations/slack/actions.ts index 8458ab2..78d8655 100644 --- a/packages/worker/src/integrations/slack/actions.ts +++ b/packages/worker/src/integrations/slack/actions.ts @@ -173,7 +173,9 @@ export function parseSlackFreeformSubmission(payload: unknown): SlackFreeformSub const input = parsed.data.view.state.values.codevil_question_freeform_input ?.codevil_question_freeform_value?.value; - if (typeof input !== "string" || input.trim().length === 0) return null; + if (typeof input !== "string") return null; + const freeform = input.trim(); + if (freeform.length === 0) return null; return { teamId: parsed.data.team.id, @@ -182,7 +184,7 @@ export function parseSlackFreeformSubmission(payload: unknown): SlackFreeformSub messageTs: metadata.m, threadTs: metadata.th, requestId: metadata.q, - freeform: input, + freeform, }; } diff --git a/packages/worker/src/integrations/slack/render.ts b/packages/worker/src/integrations/slack/render.ts index 8b5074c..dd31da1 100644 --- a/packages/worker/src/integrations/slack/render.ts +++ b/packages/worker/src/integrations/slack/render.ts @@ -168,8 +168,8 @@ export function renderSlackFreeformAnswerModal(input: { type: "modal", callback_id: "codevil_question_freeform", private_metadata: input.privateMetadata, - title: plainText("Answer question"), - submit: plainText("Send"), + title: plainText("Answer Codevil"), + submit: plainText("Send answer"), close: plainText("Cancel"), blocks, }; diff --git a/packages/worker/test/slack-actions.test.mjs b/packages/worker/test/slack-actions.test.mjs index 9288ee0..1a5d363 100644 --- a/packages/worker/test/slack-actions.test.mjs +++ b/packages/worker/test/slack-actions.test.mjs @@ -188,6 +188,14 @@ test("parseSlackFreeformSubmission parses metadata and the required answer", asy }); }); +test("parseSlackFreeformSubmission trims surrounding answer whitespace", async () => { + const module = await actionsModule; + const payload = freeformSubmissionPayload(); + payload.view.state.values.codevil_question_freeform_input.codevil_question_freeform_value.value = " Use PostgreSQL \n"; + + assert.equal(module.parseSlackFreeformSubmission(payload).freeform, "Use PostgreSQL"); +}); + test("free-form interaction parsers reject malformed metadata, empty input, wrong callbacks, and missing trigger IDs", async () => { const module = await actionsModule; const malformedMetadata = freeformSubmissionPayload({ diff --git a/packages/worker/test/slack-render.test.mjs b/packages/worker/test/slack-render.test.mjs index dd43dfd..b7736fc 100644 --- a/packages/worker/test/slack-render.test.mjs +++ b/packages/worker/test/slack-render.test.mjs @@ -165,6 +165,9 @@ test("renderSlackFreeformAnswerModal bounds display text and renders one require assert.equal(modal.type, "modal"); assert.equal(modal.callback_id, "codevil_question_freeform"); assert.equal(modal.private_metadata, privateMetadata); + assert.equal(modal.title.text, "Answer Codevil"); + assert.equal(modal.submit.text, "Send answer"); + assert.equal(modal.close.text, "Cancel"); assert.equal(modal.blocks[0].text.text, `*Question*\n${"Q".repeat(999)}โ€ฆ`); assert.equal(modal.blocks[1].text.text, `*Context*\n${"C".repeat(999)}โ€ฆ`); assert.deepEqual(modal.blocks[2], { From 98d1f957fbc84b3eab2824c56866815f425975dc Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Sat, 29 Aug 2026 19:21:12 +0530 Subject: [PATCH 09/12] fix(slack): keep one primary question action --- packages/worker/src/integrations/slack/render.ts | 2 +- packages/worker/test/slack-render.test.mjs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/integrations/slack/render.ts b/packages/worker/src/integrations/slack/render.ts index dd31da1..e13c9af 100644 --- a/packages/worker/src/integrations/slack/render.ts +++ b/packages/worker/src/integrations/slack/render.ts @@ -270,7 +270,7 @@ function questionActions( type: "button", action_id: "codevil_question_submit", text: plainText("Submit answer"), - style: "primary", + ...(!intent.allowFreeform ? { style: "primary" } : {}), value: submitValue, }); } diff --git a/packages/worker/test/slack-render.test.mjs b/packages/worker/test/slack-render.test.mjs index b7736fc..dbc4675 100644 --- a/packages/worker/test/slack-render.test.mjs +++ b/packages/worker/test/slack-render.test.mjs @@ -107,6 +107,17 @@ test("larger single-choice questions use a select and submit button", () => { assert.deepEqual(actions.elements.map((element) => element.type), ["static_select", "button", "button"]); assert.deepEqual(actions.elements[0].options.map((option) => option.value), ["0", "1", "2", "3", "4", "5"]); assert.equal(actions.elements[1].action_id, "codevil_question_submit"); + assert.equal(actions.elements[1].style, "primary"); +}); + +test("selectable free-form questions use Write answer as the single primary action", () => { + const options = Array.from({ length: 6 }, (_, index) => ({ id: `o${index}`, label: `Option ${index}` })); + const [message] = renderSlackNotification(questionIntent({ options, allowFreeform: true }), sessionUrl); + const actions = message.blocks.find((block) => block.type === "actions"); + const primaryButtons = actions.elements.filter((element) => element.type === "button" && element.style === "primary"); + + assert.deepEqual(primaryButtons.map((button) => button.action_id), ["codevil_question_open_freeform"]); + assert.equal(actions.elements.find((element) => element.action_id === "codevil_question_submit").style, undefined); }); test("multiple-choice questions use checkboxes through ten options", () => { @@ -114,6 +125,7 @@ test("multiple-choice questions use checkboxes through ten options", () => { const actions = message.blocks.find((block) => block.type === "actions"); assert.deepEqual(actions.elements.map((element) => element.type), ["checkboxes", "button", "button"]); assert.equal(actions.elements[1].action_id, "codevil_question_submit"); + assert.equal(actions.elements[1].style, "primary"); assert.equal(JSON.stringify(message.blocks).match(/PostgreSQL/g)?.length, 1); assert.equal(JSON.stringify(message.blocks).match(/Managed production database/g)?.length, 1); }); @@ -123,6 +135,7 @@ test("multiple-choice questions use a multi-select above ten options", () => { const [message] = renderSlackNotification(questionIntent({ options, allowMultiple: true }), sessionUrl); const actions = message.blocks.find((block) => block.type === "actions"); assert.deepEqual(actions.elements.map((element) => element.type), ["multi_static_select", "button", "button"]); + assert.equal(actions.elements[1].style, "primary"); }); test("unrepresentable questions fall back to Open session", () => { From cdc9b5275cc0e7128b7fed41f3ff6e5558dd4247 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Sat, 29 Aug 2026 19:36:12 +0530 Subject: [PATCH 10/12] feat(slack): handle modal question answers --- .../worker/src/integrations/slack/actions.ts | 192 ++++++++++++++-- .../worker/src/integrations/slack/routes.ts | 41 ++++ packages/worker/test/slack-actions.test.mjs | 205 ++++++++++++++++++ packages/worker/test/slack-routes.test.mjs | 64 ++++++ 4 files changed, 480 insertions(+), 22 deletions(-) diff --git a/packages/worker/src/integrations/slack/actions.ts b/packages/worker/src/integrations/slack/actions.ts index 78d8655..55cf20b 100644 --- a/packages/worker/src/integrations/slack/actions.ts +++ b/packages/worker/src/integrations/slack/actions.ts @@ -18,7 +18,7 @@ import { updateSlackMessage, type SlackApi, } from "./client.js"; -import { renderAnsweredSlackQuestion } from "./render.js"; +import { renderAnsweredSlackQuestion, renderSlackFreeformAnswerModal } from "./render.js"; const SlackBlockActionSchema = z.object({ type: z.literal("block_actions"), @@ -244,26 +244,8 @@ export async function processSlackQuestionAction( return; } - const profile = await fetchSlackUser(api, env.SLACK_BOT_TOKEN, action.userId); - if (profile.ok && profile.data.user && (profile.data.user.is_bot || profile.data.user.is_app_user)) return; - const displayName = profile.ok && profile.data.user - ? slackUserDisplayName(profile.data.user, action.userId) - : action.userId; - const now = new Date().toISOString(); - const actorStatement = upsertExternalActor({ - id: externalActorRowId(integrationIdValue, action.userId), - integration_id: integrationIdValue, - external_actor_id: action.userId, - display_name: displayName, - email: null, - linked_auth_user_id: null, - metadata_json: "{}", - created_at: now, - updated_at: now, - }); - await env.DB.prepare(actorStatement.sql).bind(...actorStatement.bindings).run(); - - const actor = { id: externalParticipantId("slack", action.userId), name: displayName }; + const actor = await resolveSlackHumanActor(action, env, api); + if (!actor) return; let result; try { result = await env.ORCHESTRATOR @@ -305,6 +287,172 @@ export async function processSlackQuestionAction( } } +export async function processSlackFreeformOpenAction( + action: SlackFreeformOpenAction, + env: Env, + deps: SlackActionProcessDeps = {}, +): Promise { + if (!env.SLACK_BOT_TOKEN || action.userId === env.CODEVIL_SLACK_BOT_USER_ID) return; + const api = deps.slackApi ?? createSlackWebApi(); + const integrationIdValue = integrationId("slack", action.teamId); + const linkStatement = externalSessionLinkSelect(integrationIdValue, action.channelId, action.threadTs); + const link = await env.DB + .prepare(linkStatement.sql) + .bind(...linkStatement.bindings) + .first(); + if (!link) { + await notifyActionFailure(api, env.SLACK_BOT_TOKEN, action, "This Slack thread is not linked to a Codevil session."); + return; + } + + let question; + try { + question = await env.ORCHESTRATOR + .get(env.ORCHESTRATOR.idFromName(link.session_id)) + .freeformQuestionForIntegration({ requestId: action.requestId }); + } catch { + await notifyActionFailure(api, env.SLACK_BOT_TOKEN, action, "I couldn't open that question. Please try again."); + return; + } + if (!question.ok) { + await notifyActionFailure(api, env.SLACK_BOT_TOKEN, action, question.error); + return; + } + + const privateMetadata = encodeSlackFreeformPrivateMetadata({ + requestId: action.requestId, + teamId: action.teamId, + channelId: action.channelId, + threadTs: action.threadTs, + messageTs: action.messageTs, + }); + if (!privateMetadata) { + await notifyActionFailure(api, env.SLACK_BOT_TOKEN, action, "I couldn't open that question. Please try again."); + return; + } + + try { + const opened = await api(env.SLACK_BOT_TOKEN, "views.open", { + trigger_id: action.triggerId, + view: renderSlackFreeformAnswerModal({ + question: question.question, + ...(question.context !== undefined ? { context: question.context } : {}), + privateMetadata, + }), + }); + if (opened.ok) return; + workerLogForSession(link.session_id, "WARN", "slack.question.modal_open.failed", { + error: opened.error, + channel_id: action.channelId, + message_ts: action.messageTs, + }, collectWorkerSecretValues(env)); + } catch (error) { + workerLogForSession(link.session_id, "WARN", "slack.question.modal_open.failed", { + error: error instanceof Error ? error.message : String(error), + channel_id: action.channelId, + message_ts: action.messageTs, + }, collectWorkerSecretValues(env)); + } + await notifyActionFailure(api, env.SLACK_BOT_TOKEN, action, "I couldn't open that question. Please try again."); +} + +export async function processSlackFreeformSubmission( + submission: SlackFreeformSubmission, + env: Env, + deps: SlackActionProcessDeps = {}, +): Promise { + if (!env.SLACK_BOT_TOKEN || submission.userId === env.CODEVIL_SLACK_BOT_USER_ID) return; + const api = deps.slackApi ?? createSlackWebApi(); + const integrationIdValue = integrationId("slack", submission.teamId); + const linkStatement = externalSessionLinkSelect(integrationIdValue, submission.channelId, submission.threadTs); + const link = await env.DB + .prepare(linkStatement.sql) + .bind(...linkStatement.bindings) + .first(); + if (!link) { + await notifyActionFailure(api, env.SLACK_BOT_TOKEN, submission, "This Slack thread is not linked to a Codevil session."); + return; + } + + const actor = await resolveSlackHumanActor(submission, env, api); + if (!actor) return; + + let result; + try { + result = await env.ORCHESTRATOR + .get(env.ORCHESTRATOR.idFromName(link.session_id)) + .answerQuestionFromIntegration({ + requestId: submission.requestId, + freeform: submission.freeform, + actor, + }); + } catch { + await notifyActionFailure(api, env.SLACK_BOT_TOKEN, submission, "I couldn't submit that answer. Please try again."); + return; + } + if (!result.ok) { + await notifyActionFailure(api, env.SLACK_BOT_TOKEN, submission, result.error); + return; + } + + try { + const update = await updateSlackMessage(api, env.SLACK_BOT_TOKEN, { + channel: submission.channelId, + ts: submission.messageTs, + ...renderAnsweredSlackQuestion({ + question: result.question, + selectedLabels: result.selectedLabels, + answeredByText: slackAnswererText(result.answeredBy), + }), + }); + if (!update.ok) { + workerLogForSession(link.session_id, "WARN", "slack.question.update.failed", { + error: update.error, + channel_id: submission.channelId, + message_ts: submission.messageTs, + }, collectWorkerSecretValues(env)); + } + } catch (error) { + workerLogForSession(link.session_id, "WARN", "slack.question.update.failed", { + error: error instanceof Error ? error.message : String(error), + channel_id: submission.channelId, + message_ts: submission.messageTs, + }, collectWorkerSecretValues(env)); + } + if (result.status === "already_answered") { + await notifyActionFailure(api, env.SLACK_BOT_TOKEN, submission, "This question was already answered."); + } +} + +async function resolveSlackHumanActor( + action: Pick, + env: Env, + api: SlackApi, +): Promise<{ id: string; name: string } | null> { + const botToken = env.SLACK_BOT_TOKEN; + if (!botToken) return null; + const profile = await fetchSlackUser(api, botToken, action.userId); + if (profile.ok && profile.data.user && (profile.data.user.is_bot || profile.data.user.is_app_user)) return null; + const displayName = profile.ok && profile.data.user + ? slackUserDisplayName(profile.data.user, action.userId) + : action.userId; + const integrationIdValue = integrationId("slack", action.teamId); + const now = new Date().toISOString(); + const actorStatement = upsertExternalActor({ + id: externalActorRowId(integrationIdValue, action.userId), + integration_id: integrationIdValue, + external_actor_id: action.userId, + display_name: displayName, + email: null, + linked_auth_user_id: null, + metadata_json: "{}", + created_at: now, + updated_at: now, + }); + await env.DB.prepare(actorStatement.sql).bind(...actorStatement.bindings).run(); + return { id: externalParticipantId("slack", action.userId), name: displayName }; +} + function slackAnswererText(actor: { id: string; name: string }): string { const slackId = actor.id.match(/^external:slack:([A-Z0-9]+)$/)?.[1]; return slackId ? `<@${slackId}>` : escapeSlackText(actor.name); @@ -317,7 +465,7 @@ function escapeSlackText(value: string): string { async function notifyActionFailure( api: SlackApi, botToken: string, - action: SlackQuestionAction, + action: Pick, text: string, ): Promise { await postSlackEphemeral(api, botToken, { diff --git a/packages/worker/src/integrations/slack/routes.ts b/packages/worker/src/integrations/slack/routes.ts index 243f351..2822249 100644 --- a/packages/worker/src/integrations/slack/routes.ts +++ b/packages/worker/src/integrations/slack/routes.ts @@ -42,8 +42,14 @@ import { formatSlackAgentRequest } from "./context.js"; import { buildSlackManifest } from "./manifest.js"; import { isSlackNonSubmittingAction, + parseSlackFreeformOpenAction, + parseSlackFreeformSubmission, parseSlackQuestionAction, + processSlackFreeformOpenAction, + processSlackFreeformSubmission, processSlackQuestionAction, + type SlackFreeformOpenAction, + type SlackFreeformSubmission, type SlackQuestionAction, } from "./actions.js"; @@ -64,6 +70,16 @@ export interface SlackEventDeps { export interface SlackActionDeps { slackApi?: SlackApi; waitUntil?: (promise: Promise) => void; + processFreeformOpenAction?: ( + action: SlackFreeformOpenAction, + env: Env, + deps: { slackApi?: SlackApi; workerOrigin?: string }, + ) => Promise; + processFreeformSubmission?: ( + submission: SlackFreeformSubmission, + env: Env, + deps: { slackApi?: SlackApi; workerOrigin?: string }, + ) => Promise; processAction?: ( action: SlackQuestionAction, env: Env, @@ -352,6 +368,31 @@ export async function handleSlackAction( } if (isSlackNonSubmittingAction(payload)) return json({ ok: true }, 200); + + const freeformOpenAction = parseSlackFreeformOpenAction(payload); + if (freeformOpenAction) { + const process = deps.processFreeformOpenAction ?? processSlackFreeformOpenAction; + const processing = process(freeformOpenAction, env, { + slackApi: deps.slackApi, + workerOrigin: new URL(request.url).origin, + }); + if (deps.waitUntil) deps.waitUntil(processing); + else await processing; + return json({ ok: true }, 200); + } + + const freeformSubmission = parseSlackFreeformSubmission(payload); + if (freeformSubmission) { + const process = deps.processFreeformSubmission ?? processSlackFreeformSubmission; + const processing = process(freeformSubmission, env, { + slackApi: deps.slackApi, + workerOrigin: new URL(request.url).origin, + }); + if (deps.waitUntil) deps.waitUntil(processing); + else await processing; + return new Response(null, { status: 200 }); + } + const action = parseSlackQuestionAction(payload); if (!action) return json({ error: "Unsupported Slack action" }, 400); diff --git a/packages/worker/test/slack-actions.test.mjs b/packages/worker/test/slack-actions.test.mjs index 1a5d363..e6911bc 100644 --- a/packages/worker/test/slack-actions.test.mjs +++ b/packages/worker/test/slack-actions.test.mjs @@ -450,3 +450,208 @@ test("processSlackQuestionAction leaves controls intact when session submission assert.ok(fixture.slackCalls.some((call) => call.method === "chat.postEphemeral")); } }); + +function freeformAction(overrides = {}) { + return { + teamId: "T123", + userId: "U123", + channelId: "C123", + messageTs: "171951.0002", + threadTs: "171951.0001", + requestId: "question_1", + triggerId: "1337.abc", + ...overrides, + }; +} + +function freeformSubmission(overrides = {}) { + return { + teamId: "T123", + userId: "U123", + channelId: "C123", + messageTs: "171951.0002", + threadTs: "171951.0001", + requestId: "question_1", + freeform: "Use a stronger, shorter headline.", + ...overrides, + }; +} + +function freeformFixture({ + linkExists = true, + questionResult = { ok: true, question: "What should the headline say?", context: "The current headline is too long." }, + answerResult = { + ok: true, + status: "answered", + question: "What should the headline say?", + selectedLabels: ["Use a stronger, shorter headline."], + answeredBy: { id: "external:slack:U123", name: "krish" }, + }, + profileFlags = {}, + viewsOpenFailure = false, + chatUpdateFailure = false, + chatUpdateThrows = false, +} = {}) { + const records = []; + const slackCalls = []; + const freeformQuestionCalls = []; + const answerCalls = []; + const link = { + id: "esl_1", + integration_id: "int_slack_T123", + external_channel_id: "C123", + external_conversation_id: "171951.0001", + session_id: "ses_123", + }; + const env = { + SLACK_BOT_TOKEN: "xoxb-test", + CODEVIL_SLACK_BOT_USER_ID: "U999", + DB: { + prepare(sql) { + const record = { sql, bindings: [] }; + records.push(record); + return { + bind(...bindings) { + record.bindings = bindings; + return { + first: async () => linkExists ? link : null, + run: async () => ({ success: true, meta: { changes: 1 } }), + }; + }, + }; + }, + }, + ORCHESTRATOR: { + idFromName: (name) => name, + get: (sessionId) => ({ + freeformQuestionForIntegration(args) { + freeformQuestionCalls.push({ sessionId, args }); + return questionResult; + }, + answerQuestionFromIntegration(args) { + answerCalls.push({ sessionId, args }); + return answerResult; + }, + }), + }, + }; + const slackApi = async (_token, method, body) => { + slackCalls.push({ method, body }); + if (method === "users.info") { + return { + ok: true, + data: { + ok: true, + user: { + id: "U123", + profile: { display_name: "krish" }, + is_bot: false, + is_app_user: false, + ...profileFlags, + }, + }, + }; + } + if (method === "views.open" && viewsOpenFailure) return { ok: false, error: "trigger_expired" }; + if (method === "chat.update" && chatUpdateThrows) throw new Error("message update failed"); + if (method === "chat.update" && chatUpdateFailure) return { ok: false, error: "message_not_found" }; + return { ok: true, data: { ok: true } }; + }; + return { env, slackApi, records, slackCalls, freeformQuestionCalls, answerCalls }; +} + +test("processSlackFreeformOpenAction opens a modal with trusted question copy and exact Slack coordinates", async () => { + const module = await actionsModule; + assert.equal(typeof module.processSlackFreeformOpenAction, "function"); + const fixture = freeformFixture(); + + await module.processSlackFreeformOpenAction(freeformAction(), fixture.env, { slackApi: fixture.slackApi }); + + assert.deepEqual(fixture.freeformQuestionCalls, [{ + sessionId: "ses_123", + args: { requestId: "question_1" }, + }]); + const open = fixture.slackCalls.find((call) => call.method === "views.open"); + assert.ok(open); + assert.equal(open.body.trigger_id, "1337.abc"); + assert.match(JSON.stringify(open.body.view.blocks), /What should the headline say\?/); + assert.match(JSON.stringify(open.body.view.blocks), /The current headline is too long\./); + assert.deepEqual(JSON.parse(open.body.view.private_metadata), { + v: 1, + q: "question_1", + t: "T123", + c: "C123", + th: "171951.0001", + m: "171951.0002", + }); +}); + +test("processSlackFreeformOpenAction reports missing links, stale questions, and views.open failures ephemerally", async () => { + const module = await actionsModule; + for (const { fixture, viewsExpected } of [ + { fixture: freeformFixture({ linkExists: false }), viewsExpected: false }, + { + fixture: freeformFixture({ questionResult: { ok: false, status: "not_open", error: "Question is no longer open" } }), + viewsExpected: false, + }, + { fixture: freeformFixture({ viewsOpenFailure: true }), viewsExpected: true }, + ]) { + await module.processSlackFreeformOpenAction(freeformAction(), fixture.env, { slackApi: fixture.slackApi }); + assert.ok(fixture.slackCalls.some((call) => call.method === "chat.postEphemeral")); + assert.equal(fixture.slackCalls.some((call) => call.method === "views.open"), viewsExpected); + } +}); + +test("processSlackFreeformSubmission resolves the human actor, accepts text, and updates the original question message", async () => { + const module = await actionsModule; + assert.equal(typeof module.processSlackFreeformSubmission, "function"); + const fixture = freeformFixture(); + + await module.processSlackFreeformSubmission(freeformSubmission(), fixture.env, { slackApi: fixture.slackApi }); + + assert.deepEqual(fixture.answerCalls, [{ + sessionId: "ses_123", + args: { + requestId: "question_1", + freeform: "Use a stronger, shorter headline.", + actor: { id: "external:slack:U123", name: "krish" }, + }, + }]); + const update = fixture.slackCalls.find((call) => call.method === "chat.update"); + assert.equal(update.body.ts, "171951.0002"); + assert.match(JSON.stringify(update.body.blocks), /Use a stronger, shorter headline\./); +}); + +test("processSlackFreeformSubmission rejects bot users and stale answers without consuming them", async () => { + const module = await actionsModule; + const bot = freeformFixture({ profileFlags: { is_bot: true } }); + await module.processSlackFreeformSubmission(freeformSubmission(), bot.env, { slackApi: bot.slackApi }); + assert.equal(bot.answerCalls.length, 0); + + const stale = freeformFixture({ + answerResult: { ok: false, status: "not_open", error: "Question is no longer open" }, + }); + await module.processSlackFreeformSubmission(freeformSubmission(), stale.env, { slackApi: stale.slackApi }); + assert.equal(stale.slackCalls.some((call) => call.method === "chat.update"), false); + assert.ok(stale.slackCalls.some((call) => call.method === "chat.postEphemeral")); +}); + +test("processSlackFreeformSubmission keeps an accepted answer when chat.update fails", async () => { + const module = await actionsModule; + const fixture = freeformFixture({ chatUpdateFailure: true }); + + await module.processSlackFreeformSubmission(freeformSubmission(), fixture.env, { slackApi: fixture.slackApi }); + + assert.equal(fixture.answerCalls.length, 1); + assert.equal(fixture.slackCalls.some((call) => call.method === "chat.postEphemeral"), false); +}); + +test("processSlackFreeformSubmission logs a thrown chat.update failure after accepting the answer", async () => { + const module = await actionsModule; + const fixture = freeformFixture({ chatUpdateThrows: true }); + + await module.processSlackFreeformSubmission(freeformSubmission(), fixture.env, { slackApi: fixture.slackApi }); + + assert.equal(fixture.answerCalls.length, 1); + assert.equal(fixture.slackCalls.some((call) => call.method === "chat.postEphemeral"), false); +}); diff --git a/packages/worker/test/slack-routes.test.mjs b/packages/worker/test/slack-routes.test.mjs index a3397b4..39530a2 100644 --- a/packages/worker/test/slack-routes.test.mjs +++ b/packages/worker/test/slack-routes.test.mjs @@ -266,6 +266,70 @@ test("Open session URL actions are acknowledged without background processing", assert.equal(processed, false); }); +test("signed modal submission returns an empty 200, schedules processing, and never submits a new Agent Run", async () => { + const payload = { + type: "view_submission", + trigger_id: "1337.def", + team: { id: "T123" }, + user: { id: "U123" }, + view: { + callback_id: "codevil_question_freeform", + private_metadata: JSON.stringify({ + v: 1, + q: "question_1", + t: "T123", + c: "C123", + th: "171951.0001", + m: "171951.0002", + }), + state: { + values: { + codevil_question_freeform_input: { + codevil_question_freeform_value: { + type: "plain_text_input", + value: "Use a stronger, shorter headline.", + }, + }, + }, + }, + }, + }; + const body = new URLSearchParams({ payload: JSON.stringify(payload) }).toString(); + const scheduled = []; + const processed = []; + let submitAgentRequestCalled = false; + const response = await slackRoutes.handleSlackAction( + await signedSlackActionRequest(body), + { + SLACK_SIGNING_SECRET: "secret", + ORCHESTRATOR: fakeOrchestrator(() => ({ + submitAgentRequest: async () => { + submitAgentRequestCalled = true; + }, + })), + }, + { + processFreeformSubmission: async (submission) => { processed.push(submission); }, + waitUntil: (promise) => { scheduled.push(promise); }, + }, + ); + + assert.equal(response.status, 200); + assert.equal(await response.text(), ""); + assert.equal(scheduled.length, 1); + await Promise.all(scheduled); + assert.deepEqual(processed, [{ + teamId: "T123", + userId: "U123", + channelId: "C123", + messageTs: "171951.0002", + threadTs: "171951.0001", + requestId: "question_1", + freeform: "Use a stronger, shorter headline.", + }]); + assert.equal(submitAgentRequestCalled, false); +}); + test("event ignores app mentions when bot user id is missing", async () => { const db = fakeD1(); const body = JSON.stringify({ From 4e85852fd9aca492e169d71b7944eaa9e7ed6ed7 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Sun, 6 Sep 2026 13:51:17 +0530 Subject: [PATCH 11/12] feat(slack): show Agent Run progress via thread status Replace live run cards with assistant.threads.setStatus so Slack shows the current step until the final reply posts. --- .../integrations/external-run-presentation.ts | 3 +- .../worker/src/integrations/slack/client.ts | 14 +- .../src/integrations/slack/live-run-card.ts | 352 ++++++++-------- .../worker/src/integrations/slack/render.ts | 107 ----- packages/worker/test/live-run-card.test.mjs | 382 ++++-------------- packages/worker/test/slack-client.test.mjs | 24 ++ packages/worker/test/slack-render.test.mjs | 20 - 7 files changed, 282 insertions(+), 620 deletions(-) diff --git a/packages/worker/src/integrations/external-run-presentation.ts b/packages/worker/src/integrations/external-run-presentation.ts index 63a6a71..eebe3a9 100644 --- a/packages/worker/src/integrations/external-run-presentation.ts +++ b/packages/worker/src/integrations/external-run-presentation.ts @@ -31,9 +31,8 @@ export interface ExternalRunEvent { const MAX_TITLE_LENGTH = 120; const MAX_SUMMARY_LENGTH = 180; const MAX_DETAIL_LENGTH = 60; -/** Internal cap so the render fingerprint stays bounded; rendering windows further. */ +/** Internal cap so the status fingerprint stays bounded. */ const MAX_KEPT_STEPS = 10; -export const MAX_VISIBLE_STEPS = 3; export function createExternalRunPresentation(runId: string, requestText: string): ExternalRunPresentation { return { diff --git a/packages/worker/src/integrations/slack/client.ts b/packages/worker/src/integrations/slack/client.ts index 3160c44..749c42d 100644 --- a/packages/worker/src/integrations/slack/client.ts +++ b/packages/worker/src/integrations/slack/client.ts @@ -49,7 +49,7 @@ export function createSlackWebApi(fetcher: typeof fetch = fetch): SlackApi { ): Promise> { let response: Response; try { - response = await fetcher(`https://slack.com/api/${method}`, slackRequestInit(botToken, method, body)); + response = await fetcher(`https://slack.com/api/${method}`, { ...slackRequestInit(botToken, method, body), signal: AbortSignal.timeout(10_000) }); } catch { return { ok: false, error: "network_error" }; } @@ -150,6 +150,18 @@ export function postSlackMessage( }) as Promise>; } +export function setSlackThreadStatus( + api: SlackApi, + botToken: string, + input: { channelId: string; threadTs: string; status: string }, +): Promise> { + return api(botToken, "assistant.threads.setStatus", { + channel_id: input.channelId, + thread_ts: input.threadTs, + status: input.status, + }); +} + export function updateSlackMessage( api: SlackApi, botToken: string, diff --git a/packages/worker/src/integrations/slack/live-run-card.ts b/packages/worker/src/integrations/slack/live-run-card.ts index 10eb189..882fa48 100644 --- a/packages/worker/src/integrations/slack/live-run-card.ts +++ b/packages/worker/src/integrations/slack/live-run-card.ts @@ -1,29 +1,29 @@ +import type { DeliveryState } from "../notification-delivery.js"; import type { DOToCLIEvent } from "@codevil/shared"; import type { Env } from "../../orchestrator/types.js"; import { redactEvent } from "../../redaction.js"; import { workerLogForSession } from "../../logging.js"; import { externalConversationDestinationBySessionSelect } from "../store.js"; import type { ExternalConversationDestination } from "../types.js"; -import { externalSessionUrl } from "../session-url.js"; import { createSlackWebApi, - deleteSlackMessage, - postSlackMessage, - updateSlackMessage, + setSlackThreadStatus, type SlackApi, type SlackApiResult, } from "./client.js"; -import { renderSlackRunCard } from "./render.js"; import { projectExternalRunEvents, type ExternalRunPresentation, + type ExternalRunStep, } from "../external-run-presentation.js"; import { notifyExternalConversation } from "../notify-external-conversation.js"; const CARD_COALESCE_MS = 2_000; +const STATUS_HEARTBEAT_MS = 90_000; const MAX_DELIVERY_ATTEMPTS = 3; const BASE_RETRY_DELAY_MS = 500; const MAX_RETRY_DELAY_MS = 5_000; +const MAX_STATUS_LENGTH = 150; interface LiveRunPresentationRow { run_id: string; @@ -53,6 +53,7 @@ export class LiveRunCardCoordinator { private readonly scheduleAlarm: (when: number) => void, api?: SlackApi, sleep: (delayMs: number) => Promise = sleepFor, + private readonly deliverNotification?: (cursor: number, event: DOToCLIEvent) => DeliveryState, ) { this.api = api ?? createSlackWebApi(); this.sleep = sleep; @@ -62,8 +63,7 @@ export class LiveRunCardCoordinator { const runId = runIdForEvent(event) ?? activeRunId; if (!runId || !isLiveRunEvent(event)) return; - const destination = await this.destination(); - if (!destination) return; + if (!this.env.SLACK_BOT_TOKEN) return; const now = Date.now(); const existing = this.row(runId); @@ -82,7 +82,7 @@ export class LiveRunCardCoordinator { last_render_fingerprint: existing?.last_render_fingerprint ?? null, pending_final_response_cursor: pendingFinalResponseCursor, next_retry_at: nextRetryAt(existing?.next_retry_at ?? null, event, now), - card_delete_pending_at: existing?.card_delete_pending_at ?? null, + card_delete_pending_at: null, created_at: existing?.created_at ?? new Date(now).toISOString(), updated_at: new Date(now).toISOString(), }); @@ -90,7 +90,7 @@ export class LiveRunCardCoordinator { try { await this.flush(runId); } catch (error) { - this.log("ERROR", "live_run_card.flush.failed", runId, { error: redactEvent(error, this.envSecrets()) }); + this.log("ERROR", "slack_thread_status.flush.failed", runId, { error: redactEvent(error, this.envSecrets()) }); } } @@ -113,118 +113,140 @@ export class LiveRunCardCoordinator { for (;;) { const row = this.row(runId); if (!row) return; - // The run resolved and the response was delivered: only the card - // teardown remains. Never re-render in this state. - if (row.card_delete_pending_at !== null) { - await this.closeCard(row); - return; - } const now = Date.now(); if (!force && row.next_retry_at !== null && row.next_retry_at > now) return; force = false; const presentation = this.project(runId); - const fingerprint = JSON.stringify(presentation); - if (row.last_delivered_cursor >= row.last_projected_cursor && row.last_render_fingerprint === fingerprint) { + if (row.presentation_status === "uncertain") { + this.upsert({ ...row, next_retry_at: null }); if (isTerminal(presentation)) await this.deliverFinalResponse(row, presentation); return; } - const destination = await this.destination(); - if (!destination || !this.env.SLACK_BOT_TOKEN) return; - const message = renderSlackRunCard(presentation, externalSessionUrl({ CODEVIL_WEB_ORIGIN: this.env.CODEVIL_WEB_ORIGIN }, this.workerOrigin(), this.sessionId()), row.last_projected_cursor); - const delivered = await this.deliverCard(row, destination, message, presentation); + const status = slackThreadStatus(presentation); + const fingerprint = status ?? ""; + if (isTerminal(presentation)) { + this.upsert({ + ...row, + last_delivered_cursor: Math.max(row.last_delivered_cursor, row.last_projected_cursor), + last_render_fingerprint: fingerprint, + next_retry_at: null, + updated_at: new Date().toISOString(), + }); + await this.deliverFinalResponse(this.row(runId) ?? row, presentation); + return; + } + + if (status === null) { + this.upsert({ + ...row, + last_delivered_cursor: Math.max(row.last_delivered_cursor, row.last_projected_cursor), + last_render_fingerprint: fingerprint, + next_retry_at: null, + updated_at: new Date().toISOString(), + }); + return; + } + + if (row.last_delivered_cursor >= row.last_projected_cursor && row.last_render_fingerprint === fingerprint) { + const heartbeat = await this.deliverStatus(row, status); + if (!heartbeat.ok) { + if (heartbeat.uncertain) { + this.upsert({ ...row, presentation_status: "uncertain", next_retry_at: null }); + return; + } + const retryAt = Date.now() + heartbeat.retryAfterMs; + this.upsert({ ...row, next_retry_at: retryAt, updated_at: new Date().toISOString() }); + this.scheduleAlarm(retryAt); + return; + } + const retryAt = Date.now() + STATUS_HEARTBEAT_MS; + this.upsert({ ...row, next_retry_at: retryAt, updated_at: new Date().toISOString() }); + this.scheduleAlarm(retryAt); + return; + } + + const delivered = await this.deliverStatus(row, status); if (!delivered.ok) { - this.upsert({ ...row, next_retry_at: Date.now() + delivered.retryAfterMs, updated_at: new Date().toISOString() }); - if (isTerminal(presentation)) await this.deliverFinalResponse(row, presentation); - this.scheduleAlarm(Date.now() + delivered.retryAfterMs); + if (delivered.uncertain) { + this.upsert({ ...row, presentation_status: "uncertain", next_retry_at: null }); + return; + } + const retryAt = Date.now() + delivered.retryAfterMs; + this.upsert({ ...row, next_retry_at: retryAt, updated_at: new Date().toISOString() }); + this.scheduleAlarm(retryAt); return; } const current = this.row(runId) ?? row; + const retryAt = Date.now() + STATUS_HEARTBEAT_MS; this.upsert({ ...current, - external_message_id: delivered.messageId ?? current.external_message_id, presentation_status: presentation.status, last_delivered_cursor: Math.max(current.last_delivered_cursor, row.last_projected_cursor), last_render_fingerprint: fingerprint, - next_retry_at: null, + next_retry_at: retryAt, updated_at: new Date().toISOString(), }); - if (isTerminal(presentation)) await this.deliverFinalResponse(this.row(runId) ?? current, presentation); + this.scheduleAlarm(retryAt); const latest = this.row(runId); if (!latest || latest.last_projected_cursor <= latest.last_delivered_cursor) return; - const latestPresentation = this.project(runId); - if (!isTerminal(latestPresentation) && latest.next_retry_at !== null && latest.next_retry_at > Date.now()) return; + if (latest.next_retry_at !== null && latest.next_retry_at > Date.now()) return; } } finally { this.flushing.delete(runId); } } - private async deliverCard( + private async deliverStatus( row: LiveRunPresentationRow, - destination: ExternalConversationDestination, - message: ReturnType, - presentation: ExternalRunPresentation, - ): Promise<{ ok: true; messageId?: string } | { ok: false; retryAfterMs: number }> { - const retry = async (operation: () => Promise>): Promise> => { - for (let attempt = 1; attempt <= MAX_DELIVERY_ATTEMPTS; attempt += 1) { - const result = await operation(); - if (result.ok) return result; - if (!isRetryable(result) || attempt === MAX_DELIVERY_ATTEMPTS) { - this.log("ERROR", "live_run_card.delivery.exhausted", row.run_id, { cursor: row.last_projected_cursor, attempt, error: result.error }); - return result; - } - const delay = retryDelay(result, attempt); - this.log("WARN", "live_run_card.delivery.retrying", row.run_id, { cursor: row.last_projected_cursor, attempt, delay_ms: delay, error: result.error }); - await this.sleep(delay); + status: string, + ): Promise<{ ok: true } | { ok: false; retryAfterMs: number; uncertain?: boolean }> { + const destination = await this.destination(); + if (!destination || !this.env.SLACK_BOT_TOKEN) { + return { ok: false, retryAfterMs: BASE_RETRY_DELAY_MS, uncertain: true }; + } + + for (let attempt = 1; attempt <= MAX_DELIVERY_ATTEMPTS; attempt += 1) { + let result: SlackApiResult; + try { + result = await setSlackThreadStatus(this.api, this.env.SLACK_BOT_TOKEN, { + channelId: destination.external_channel_id, + threadTs: destination.external_conversation_id, + status, + }); + } catch { + result = { ok: false, error: "network_error" }; } - return { ok: false, error: "retry_exhausted" }; - }; - - if (!row.external_message_id) { - const posted = await retry(() => postSlackMessage(this.api, this.env.SLACK_BOT_TOKEN!, { - channel: destination.external_channel_id, - threadTs: destination.external_conversation_id, - ...message, - })); - if (posted.ok) { - const data = posted.data as { ts?: unknown }; - if (typeof data.ts === "string") return { ok: true, messageId: data.ts }; - return { ok: false, retryAfterMs: BASE_RETRY_DELAY_MS }; + if (result.ok) return { ok: true }; + if (isPermanentStatusFailure(result)) { + this.log("ERROR", "slack_thread_status.delivery.permanent", row.run_id, { + cursor: row.last_projected_cursor, + attempt, + error: result.error, + }); + return { ok: false, retryAfterMs: BASE_RETRY_DELAY_MS, uncertain: true }; } - if (isUnsupportedCardFailure(posted)) { - const fallback = await retry(() => postSlackMessage(this.api, this.env.SLACK_BOT_TOKEN!, { - channel: destination.external_channel_id, - threadTs: destination.external_conversation_id, - text: fallbackText(presentation, this.workerOrigin(), this.sessionId(), this.env.CODEVIL_WEB_ORIGIN), - })); - if (fallback.ok) { - const data = fallback.data as { ts?: unknown }; - if (typeof data.ts === "string") return { ok: true, messageId: data.ts }; - } + if (result.retryAfterMs !== undefined || !isRetryable(result) || attempt === MAX_DELIVERY_ATTEMPTS) { + this.log("ERROR", "slack_thread_status.delivery.exhausted", row.run_id, { + cursor: row.last_projected_cursor, + attempt, + error: result.error, + }); + return { ok: false, retryAfterMs: retryDelay(result, MAX_DELIVERY_ATTEMPTS) }; } - return { ok: false, retryAfterMs: retryDelay(posted, MAX_DELIVERY_ATTEMPTS) }; - } - - const updated = await retry(() => updateSlackMessage(this.api, this.env.SLACK_BOT_TOKEN!, { - channel: destination.external_channel_id, - ts: row.external_message_id!, - ...message, - })); - if (updated.ok) return { ok: true, messageId: row.external_message_id }; - if (isUnsupportedCardFailure(updated)) { - const fallback = await retry(() => updateSlackMessage(this.api, this.env.SLACK_BOT_TOKEN!, { - channel: destination.external_channel_id, - ts: row.external_message_id!, - text: fallbackText(presentation, this.workerOrigin(), this.sessionId(), this.env.CODEVIL_WEB_ORIGIN), - })); - if (fallback.ok) return { ok: true, messageId: row.external_message_id }; - return { ok: false, retryAfterMs: retryDelay(fallback, MAX_DELIVERY_ATTEMPTS) }; + const delay = retryDelay(result, attempt); + this.log("WARN", "slack_thread_status.delivery.retrying", row.run_id, { + cursor: row.last_projected_cursor, + attempt, + delay_ms: delay, + error: result.error, + }); + await this.sleep(delay); } - return { ok: false, retryAfterMs: retryDelay(updated, MAX_DELIVERY_ATTEMPTS) }; + return { ok: false, retryAfterMs: BASE_RETRY_DELAY_MS }; } private async deliverFinalResponse(row: LiveRunPresentationRow, presentation: ExternalRunPresentation): Promise { @@ -239,7 +261,13 @@ export class LiveRunCardCoordinator { : terminal.event.type === "agent_run_failed" ? terminal.event : { type: "agent_response", run_id: row.run_id, text: `Completed.${presentation.prUrl ? ` Draft PR: ${presentation.prUrl}` : ""}` } as DOToCLIEvent; - const delivered = await notifyExternalConversation({ + const durable = this.deliverNotification?.(response?.cursor ?? terminal.cursor, event); + if (durable && durable.status !== "delivered") { + this.upsert({ ...row, next_retry_at: durable.nextRetryAt, updated_at: new Date().toISOString() }); + if (durable.nextRetryAt !== null) this.scheduleAlarm(Math.max(durable.nextRetryAt, Date.now() + 1)); + return; + } + const delivered = durable?.status === "delivered" || await notifyExternalConversation({ env: this.env, sessionId: this.sessionId(), workerOrigin: this.workerOrigin(), @@ -247,22 +275,7 @@ export class LiveRunCardCoordinator { event, }, { slackApi: this.api, sleep: this.sleep, random: () => 0 }); if (delivered) { - const latest = this.row(row.run_id); - if (latest) { - const now = Date.now(); - // Mark teardown atomically with delivery: the row always carries a - // due next_retry_at, so a restart mid-delete re-arms via - // nextRetryAt()/drainDue() instead of stranding the card. - this.upsert({ - ...latest, - pending_final_response_cursor: null, - card_delete_pending_at: now, - next_retry_at: now, - updated_at: new Date().toISOString(), - }); - this.scheduleAlarm(now + 1); - await this.closeCard(this.row(row.run_id) ?? latest); - } + this.deleteRow(row.run_id); } else { const latest = this.row(row.run_id); if (latest) { @@ -273,61 +286,6 @@ export class LiveRunCardCoordinator { } } - /** - * Tear down the card for a resolved run: delete the Slack message, then drop - * the presentation row. Survives restarts via the DO alarm when the delete - * itself fails. - */ - private async closeCard(row: LiveRunPresentationRow): Promise { - if (!row.external_message_id) { - this.deleteRow(row.run_id); - return; - } - const destination = await this.destination(); - if (!destination || !this.env.SLACK_BOT_TOKEN) { - // Nothing else can ever delete a card we cannot address; drop the row so - // the session does not keep retrying forever. - this.log("WARN", "live_run_card.close.no_destination", row.run_id, {}); - this.deleteRow(row.run_id); - return; - } - const deleting = this.row(row.run_id) ?? row; - if (deleting.card_delete_pending_at === null) { - this.upsert({ ...deleting, card_delete_pending_at: Date.now(), updated_at: new Date().toISOString() }); - } - for (let attempt = 1; attempt <= MAX_DELIVERY_ATTEMPTS; attempt += 1) { - const result = await deleteSlackMessage(this.api, this.env.SLACK_BOT_TOKEN, { - channel: destination.external_channel_id, - ts: row.external_message_id!, - }); - if (result.ok || isMessageAlreadyGone(result)) { - this.deleteRow(row.run_id); - return; - } - if (!isRetryable(result)) { - this.log("ERROR", "live_run_card.close.exhausted", row.run_id, { attempt, error: result.error, status: result.status }); - // Permanent failure (auth etc.): leave the card in place and stop - // retrying โ€” the response messages are already delivered. - this.deleteRow(row.run_id); - return; - } - if (attempt === MAX_DELIVERY_ATTEMPTS) { - // Transient failures that outlast the in-flight retries: keep the row - // and let the DO alarm retry the delete later. - const latest = this.row(row.run_id); - if (latest) { - const nextRetryAt = Date.now() + BASE_RETRY_DELAY_MS; - this.upsert({ ...latest, next_retry_at: nextRetryAt, updated_at: new Date().toISOString() }); - this.scheduleAlarm(nextRetryAt); - } - return; - } - const delay = retryDelay(result, attempt); - this.log("WARN", "live_run_card.close.retrying", row.run_id, { attempt, delay_ms: delay, error: result.error }); - await this.sleep(delay); - } - } - private project(runId: string): ExternalRunPresentation { return projectExternalRunEvents(this.eventsForRun(runId)); } @@ -342,13 +300,6 @@ export class LiveRunCardCoordinator { return []; } }); - // Runs interleave in the log: a request is logged immediately, the run may - // start much later after queued requests from other turns. Global progress - // events (status/phase/agent_event) carry no run id, so attribute them to - // the run whose execution window covers the cursor: from that run's - // agent_run_started until its own terminal event. A queued run has no - // execution window yet โ€” its own request/queue events only, never another - // turn's progress. const runEntries = parsed.filter((entry) => runIdForEvent(entry.event) === runId); if (runEntries.length === 0) return []; const startIndex = runEntries.findIndex((entry) => entry.event.type === "agent_run_started"); @@ -419,6 +370,27 @@ export class LiveRunCardCoordinator { } } +export function slackThreadStatus(presentation: ExternalRunPresentation): string | null { + if (presentation.waitingFor !== undefined) return null; + if (presentation.status !== "in_progress") return null; + if (presentation.queuedPosition !== undefined) { + return `is in queue (position ${presentation.queuedPosition})...`; + } + const active = [...presentation.steps].reverse().find((step) => step.status === "active"); + if (active) return statusFromStep(active); + return boundedStatus(`is ${presentation.phase.toLowerCase()}...`); +} + +function statusFromStep(step: ExternalRunStep): string { + const detail = step.detail ? ` โ€” ${step.detail}` : ""; + return boundedStatus(`is ${step.label.toLowerCase()}${detail}...`); +} + +function boundedStatus(value: string): string { + if (value.length <= MAX_STATUS_LENGTH) return value; + return `${value.slice(0, MAX_STATUS_LENGTH - 3).trimEnd()}...`; +} + function runIdForEvent(event: DOToCLIEvent): string | undefined { return "run_id" in event && typeof event.run_id === "string" ? event.run_id : undefined; } @@ -441,24 +413,9 @@ function shouldFlushImmediately(event: DOToCLIEvent): boolean { function nextRetryAt(existing: number | null, event: DOToCLIEvent, now: number): number { if (shouldFlushImmediately(event)) return now; - if (existing !== null && existing > now) return existing; - return now + CARD_COALESCE_MS; -} - -function fallbackText( - presentation: ExternalRunPresentation, - workerOrigin: string, - sessionId: string, - webOrigin?: string, -): string { - const state = presentation.status === "complete" - ? "Completed successfully." - : presentation.status === "error" - ? presentation.summary ?? "The Agent Run failed." - : presentation.queuedPosition !== undefined - ? `Codevil is in queue (position ${presentation.queuedPosition}).` - : `Codevil is working on: ${presentation.title}.`; - return `${state} Open session: ${externalSessionUrl({ CODEVIL_WEB_ORIGIN: webOrigin }, workerOrigin, sessionId)}`; + const coalesceAt = now + CARD_COALESCE_MS; + if (existing !== null && existing > now && existing <= coalesceAt) return existing; + return coalesceAt; } function isTerminal(presentation: ExternalRunPresentation): boolean { @@ -469,20 +426,31 @@ function isRetryable(result: Extract, { ok: false }>): b return result.status === 429 || (result.status !== undefined && result.status >= 500) || ["rate_limited", "ratelimited", "request_timeout", "network_error"].includes(result.error) || /^http_5\d\d$/.test(result.error); } -/** Deletes are idempotent: a message that is already gone counts as success. */ -function isMessageAlreadyGone(result: Extract, { ok: false }>): boolean { - return result.status === 404 || ["message_not_found", "channel_not_found", "is_archived"].includes(result.error); -} - -function isUnsupportedCardFailure(result: Extract, { ok: false }>): boolean { - return ["invalid_blocks", "invalid_blocks_format", "invalid_arguments", "method_not_supported"].includes(result.error); +function isPermanentStatusFailure(result: Extract, { ok: false }>): boolean { + return [ + "channel_not_found", + "invalid_thread_ts", + "method_not_supported_for_channel_type", + "method_deprecated", + "missing_scope", + "invalid_auth", + "no_permission", + "not_in_channel", + "not_allowed_token_type", + "feature_disabled", + "invalid_arguments", + "not_authed", + "account_inactive", + "token_revoked", + "token_expired", + ].includes(result.error); } function retryDelay(result: SlackApiResult, attempt: number): number { - if (!result.ok && result.retryAfterMs !== undefined) return Math.min(result.retryAfterMs, MAX_RETRY_DELAY_MS); + if (!result.ok && result.retryAfterMs !== undefined) return result.retryAfterMs; return Math.min(BASE_RETRY_DELAY_MS * 2 ** Math.max(0, attempt - 1), MAX_RETRY_DELAY_MS); } function sleepFor(delayMs: number): Promise { return new Promise((resolve) => setTimeout(resolve, delayMs)); -} \ No newline at end of file +} diff --git a/packages/worker/src/integrations/slack/render.ts b/packages/worker/src/integrations/slack/render.ts index e13c9af..1bd9b84 100644 --- a/packages/worker/src/integrations/slack/render.ts +++ b/packages/worker/src/integrations/slack/render.ts @@ -1,8 +1,6 @@ import type { ExternalNotificationIntent } from "../notification-intents.js"; import type { SlackMessageContent } from "./client.js"; import type { QuestionOption } from "@codevil/shared"; -import type { ExternalRunPresentation, ExternalRunStep } from "../external-run-presentation.js"; -import { MAX_VISIBLE_STEPS, validPullRequestUrl } from "../external-run-presentation.js"; const MAX_EXTERNAL_TEXT_LENGTH = 500; const MAX_SLACK_MARKDOWN_CHARS = 11_500; @@ -10,111 +8,6 @@ const MAX_SLACK_ACTION_VALUE_LENGTH = 2_000; const MAX_SLACK_OPTION_TEXT_LENGTH = 75; const MAX_SLACK_MODAL_TEXT_LENGTH = 1_000; -export function renderSlackRunCard( - presentation: ExternalRunPresentation, - sessionUrl: string, - revision: number, -): SlackMessageContent { - const details = renderDetails(presentation); - const prUrl = presentation.prUrl ? validPullRequestUrl(presentation.prUrl) : undefined; - const childBlocks: Array> = []; - if (details.length > 0) childBlocks.push(richText(details)); - childBlocks.push(renderSourceLinks(sessionUrl, prUrl)); - - const block = { - type: "container", - block_id: `codevil_run_${presentation.runId}_${revision}`.slice(0, 255), - title: plainText(presentation.title.slice(0, 120)), - subtitle: plainText(truncate(briefStatus(presentation), 150)), - is_collapsible: true, - default_collapsed: false, - child_blocks: childBlocks, - }; - return { - text: `Codevil: ${presentation.title} โ€” ${briefStatus(presentation)}. Open session: ${sessionUrl}`.slice(0, MAX_EXTERNAL_TEXT_LENGTH), - blocks: [block], - }; -} - -function renderDetails(presentation: ExternalRunPresentation): Array> { - const collapsed = collapseConsecutiveSteps(presentation.steps); - const hidden = presentation.droppedSteps - + collapsed.collapsedCount - + Math.max(0, collapsed.steps.length - MAX_VISIBLE_STEPS); - const sections: Array> = []; - if (hidden > 0) { - sections.push({ - type: "rich_text_section", - elements: [{ type: "text", text: `โ€ฆ ${hidden} earlier step${hidden === 1 ? "" : "s"}` }], - }); - } - sections.push(...collapsed.steps.slice(-MAX_VISIBLE_STEPS).map(renderStep)); - return sections; -} - -function collapseConsecutiveSteps(steps: ExternalRunStep[]): { - steps: ExternalRunStep[]; - collapsedCount: number; -} { - const collapsed: ExternalRunStep[] = []; - let collapsedCount = 0; - for (const step of steps) { - const previous = collapsed.at(-1); - if (previous && previous.label === step.label && previous.detail === step.detail) { - collapsed[collapsed.length - 1] = step; - collapsedCount += 1; - } else { - collapsed.push(step); - } - } - return { steps: collapsed, collapsedCount }; -} - -function renderStep(step: ExternalRunStep): Record { - const state = step.status === "done" - ? { glyph: "โœ“", label: "Completed" } - : step.status === "error" - ? { glyph: "ร—", label: "Failed" } - : { glyph: "โ—", label: "Running" }; - const detail = step.detail ? ` โ€” ${step.detail}` : ""; - return { - type: "rich_text_section", - elements: [ - { type: "text", text: `${state.glyph} ` }, - { type: "text", text: state.label, style: { bold: true } }, - { type: "text", text: ` โ€” ${step.label}${detail}` }, - ], - }; -} - -function briefStatus(presentation: ExternalRunPresentation): string { - if (presentation.queuedPosition !== undefined) return `In queue (position ${presentation.queuedPosition})`; - if (presentation.waitingFor === "question") return "Waiting for your answer"; - if (presentation.waitingFor === "approval") return "Waiting for plan approval"; - if (presentation.status === "in_progress") return presentation.phase; - return presentation.summary ?? presentation.status; -} - -function richText(elements: Array>): Record { - return { - type: "rich_text", - elements, - }; -} - -function renderSourceLinks(sessionUrl: string, prUrl: string | undefined): Record { - const elements: Array> = [ - { type: "link", url: sessionUrl, text: "Open Codevil" }, - ]; - if (prUrl) { - elements.push( - { type: "text", text: " ยท " }, - { type: "link", url: prUrl, text: "View pull request" }, - ); - } - return richText([{ type: "rich_text_section", elements }]); -} - export function renderSlackNotification( intent: ExternalNotificationIntent, sessionUrl: string, diff --git a/packages/worker/test/live-run-card.test.mjs b/packages/worker/test/live-run-card.test.mjs index 8d52cba..fb4111a 100644 --- a/packages/worker/test/live-run-card.test.mjs +++ b/packages/worker/test/live-run-card.test.mjs @@ -2,12 +2,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - createExternalRunPresentation, projectExternalRunEvents, - MAX_VISIBLE_STEPS, } from "../dist/integrations/external-run-presentation.js"; -import { renderSlackRunCard } from "../dist/integrations/slack/render.js"; -import { LiveRunCardCoordinator } from "../dist/integrations/slack/live-run-card.js"; +import { LiveRunCardCoordinator, slackThreadStatus } from "../dist/integrations/slack/live-run-card.js"; const started = { type: "agent_run_started", @@ -16,22 +13,6 @@ const started = { text: "Fix authentication and add tests", }; -function detailsLines(presentation, sessionUrl = "https://app.codevil.example/sessions/ses_1") { - const rendered = renderSlackRunCard(presentation, sessionUrl, 1); - return activityRows(rendered.blocks[0]).map(textFromRichTextSection); -} - -function activityRows(card) { - const activity = card.child_blocks?.find((block) => - block.type === "rich_text" && block.elements?.some((section) => - /^[โ€ฆโœ“ร—โ—]/u.test(textFromRichTextSection(section)))); - return activity?.elements ?? []; -} - -function textFromRichTextSection(section) { - return (section.elements ?? []).map((element) => element.text ?? "").join(""); -} - test("keeps the clean request title when the run starts with an enriched prompt", () => { const presentation = projectExternalRunEvents([ { cursor: 1, event: { type: "agent_request", run_id: "run_1", actor: { id: "U1", name: "Ada" }, text: "Improve landing page header and colors", created_at: "2026-08-28T00:00:00.000Z" } }, @@ -64,59 +45,6 @@ test("preserves a bounded clean started-only title", () => { assert.equal(presentation.title, "Fix authentication and add tests"); }); -test("renders a default-expanded card with chronological, explicit activity rows", () => { - const presentation = projectExternalRunEvents([ - { cursor: 1, event: { type: "agent_request", run_id: "run_1", actor: { id: "U1", name: "Ada" }, text: "Improve the landing page", created_at: "2026-08-28T00:00:00.000Z" } }, - ...[2, 3, 4].map((cursor) => ({ - cursor, - event: { type: "agent_event", event: { type: "tool_execution_end", tool: "read", toolCallId: `read_${cursor}`, success: true } }, - })), - { cursor: 5, event: { type: "agent_event", event: { type: "tool_execution_start", tool: "edit", toolCallId: "edit_1", args: { file_path: "src/Hero.astro" } } } }, - ]); - - const card = renderSlackRunCard(presentation, "https://app.codevil.example/sessions/ses_1", 7).blocks[0]; - assert.equal(card.type, "container"); - assert.equal(card.is_collapsible, true); - assert.equal(card.default_collapsed, false); - assert.equal(card.title.text, presentation.title); - assert.doesNotMatch(card.title.text, /โœ…|๐Ÿ”„|โŒ|๐Ÿ’ฌ/u); - - const activity = activityRows(card); - assert.deepEqual(activity.map(textFromRichTextSection), [ - "โ€ฆ 2 earlier steps", - "โœ“ Completed โ€” Reading files", - "โ— Running โ€” Editing code โ€” Hero.astro", - ]); - assert.deepEqual(activity.at(-1).elements[1], { - type: "text", - text: "Running", - style: { bold: true }, - }); - assert.equal(detailsLines(presentation).filter((line) => /^(?:โ—|โœ“|โœ—) /.test(line)).length, 2); -}); - -test("renders failed rows and counts dropped, collapsed, and windowed steps", () => { - const presentation = { - ...createExternalRunPresentation("run_1", "Ship the fix"), - steps: [ - { id: "old-1", label: "Reading files", status: "done", rank: 1 }, - { id: "old-2", label: "Reading files", status: "done", rank: 2 }, - { id: "middle", label: "Searching code", status: "error", rank: 3 }, - { id: "visible-1", label: "Editing code", status: "done", rank: 4 }, - { id: "visible-2", label: "Running checks", status: "error", rank: 5 }, - { id: "visible-3", label: "Publishing changes", status: "active", rank: 6 }, - ], - droppedSteps: 2, - }; - - assert.deepEqual(detailsLines(presentation), [ - "โ€ฆ 5 earlier steps", - "โœ“ Completed โ€” Editing code", - "ร— Failed โ€” Running checks", - "โ— Running โ€” Publishing changes", - ]); -}); - test("projects supported lifecycle events into a redacted, granular step list", () => { const presentation = projectExternalRunEvents([ { cursor: 1, event: started }, @@ -139,6 +67,7 @@ test("projects supported lifecycle events into a redacted, granular step list", assert.equal(presentation.phase, "Preparing"); assert.deepEqual(presentation.steps, [{ id: "call_1", label: "Running commands", detail: undefined, status: "done", rank: 3 }]); assert.doesNotMatch(JSON.stringify(presentation), /secret|private|ghp_/i); + assert.equal(slackThreadStatus(presentation), "is preparing..."); }); test("maps each tool family to a granular label and folds bash args into no detail", () => { @@ -166,6 +95,7 @@ test("maps each tool family to a granular label and folds bash args into no deta { cursor: 2, event: { type: "agent_event", event: { type: "tool_execution_start", tool: "edit", toolCallId: "c_e", args: { file_path: "src/auth/login.ts" } } } }, ]); assert.equal(withPath.steps[0].detail, "login.ts"); + assert.equal(slackThreadStatus(withPath), "is editing code โ€” login.ts..."); const bash = projectExternalRunEvents([ { cursor: 1, event: started }, @@ -175,7 +105,7 @@ test("maps each tool family to a granular label and folds bash args into no deta assert.doesNotMatch(JSON.stringify(bash), /sk-secret/i); }); -test("windows steps to the current step plus a few older ones on the card", () => { +test("windows steps to a bounded recent list", () => { const events = [{ cursor: 1, event: started }]; for (let index = 0; index < 12; index += 1) { events.push({ cursor: index + 2, event: { @@ -184,18 +114,9 @@ test("windows steps to the current step plus a few older ones on the card", () = } }); } const presentation = projectExternalRunEvents(events); - // Internal cap keeps the fingerprint bounded; the card renders only the tail. assert.equal(presentation.steps.length, 10); assert.equal(presentation.droppedSteps, 2); - assert.equal(MAX_VISIBLE_STEPS, 3); - - const lines = detailsLines(presentation); - const stepLines = lines.filter((line) => line.startsWith("โœ“")); - assert.equal(stepLines.length, 1); - assert.deepEqual(stepLines.map((line) => line.split(" โ€” ")[1]), [ - "Reading files", - ]); - assert.ok(lines.some((line) => /โ€ฆ 11 earlier steps/.test(line))); + assert.equal(slackThreadStatus(presentation), "is investigating..."); }); test("shows a queued run with its queue position until it starts", () => { @@ -205,32 +126,30 @@ test("shows a queued run with its queue position until it starts", () => { ]); assert.equal(queued.queuedPosition, 2); assert.equal(queued.phase, "Queued"); - assert.equal(renderSlackRunCard(queued, "https://app.codevil.example/sessions/ses_1", 1).blocks[0].subtitle.text, "In queue (position 2)"); + assert.equal(slackThreadStatus(queued), "is in queue (position 2)..."); const running = projectExternalRunEvents([ queued && { cursor: 3, event: started }, ].filter(Boolean)); assert.equal(running.queuedPosition, undefined); assert.equal(running.status, "in_progress"); + assert.equal(slackThreadStatus(running), "is starting..."); }); -test("renders an explicit question waiting subtitle", () => { +test("does not set status while waiting for a question or approval", () => { const waiting = projectExternalRunEvents([ { cursor: 1, event: started }, { cursor: 2, event: { type: "question_raised", request_id: "q1", run_id: "run_1", question: "Which region?", allow_freeform: false, allow_multiple: false, answerable_by: "anyone", status: "open", raised_at: "2026-08-13T00:00:00.000Z" } }, ]); assert.equal(waiting.waitingFor, "question"); - assert.equal(waiting.phase, "Waiting for input"); - assert.equal(renderSlackRunCard(waiting, "https://app.codevil.example/sessions/ses_1", 1).blocks[0].subtitle.text, "Waiting for your answer"); -}); + assert.equal(slackThreadStatus(waiting), null); -test("renders an explicit plan approval waiting subtitle", () => { const approval = projectExternalRunEvents([ { cursor: 1, event: started }, { cursor: 2, event: { type: "approval_requested", run_id: "run_1", plan: "Update the header." } }, ]); assert.equal(approval.waitingFor, "approval"); - assert.equal(renderSlackRunCard(approval, "https://app.codevil.example/sessions/ses_1", 1).blocks[0].subtitle.text, "Waiting for plan approval"); + assert.equal(slackThreadStatus(approval), null); }); test("projects terminal and deterministic completion states", () => { @@ -241,65 +160,25 @@ test("projects terminal and deterministic completion states", () => { assert.equal(complete.status, "complete"); assert.equal(complete.prUrl, "https://github.com/acme/repo/pull/12"); assert.equal(complete.summary, "Completed successfully."); + assert.equal(slackThreadStatus(complete), null); }); -test("renders a container with accessible fallback and fresh block ids", () => { - const presentation = createExternalRunPresentation("run_1", "Investigate auth"); - const first = renderSlackRunCard(presentation, "https://app.codevil.example/sessions/ses_1", 7); - const second = renderSlackRunCard(presentation, "https://app.codevil.example/sessions/ses_1", 8); - const block = first.blocks[0]; - - assert.equal(block.type, "container"); - assert.equal(block.title.text, "Investigate auth"); - assert.equal(block.subtitle.text, "Starting"); - assert.equal(block.is_collapsible, true); - assert.equal(block.default_collapsed, false); - assert.notEqual(first.blocks[0].block_id, second.blocks[0].block_id); - assert.deepEqual(block.child_blocks.at(-1).elements[0].elements, [ - { type: "link", url: "https://app.codevil.example/sessions/ses_1", text: "Open Codevil" }, - ]); - assert.match(first.text, /Investigate auth/); -}); - -test("keeps the terminal summary in the subtitle instead of repeating it in activity", () => { - const presentation = { - ...createExternalRunPresentation("run_1", "Ship"), - status: "complete", - phase: "Complete", - summary: "Completed successfully.", - }; - const rendered = renderSlackRunCard(presentation, "https://app.codevil.example/sessions/ses_1", 1); - - assert.doesNotMatch(JSON.stringify(rendered.blocks[0].child_blocks), /Completed successfully\./); - assert.equal(rendered.blocks[0].subtitle.text, "Completed successfully."); -}); - -test("renders only a validated pull-request source", () => { - const presentation = { ...createExternalRunPresentation("run_1", "Ship"), status: "complete", summary: "Completed successfully.", steps: [], droppedSteps: 0, prUrl: "https://github.com/acme/repo/pull/12" }; - const rendered = renderSlackRunCard(presentation, "https://app.codevil.example/sessions/ses_1", 1); - assert.deepEqual(rendered.blocks[0].child_blocks.at(-1).elements[0].elements, [ - { type: "link", url: "https://app.codevil.example/sessions/ses_1", text: "Open Codevil" }, - { type: "text", text: " ยท " }, - { type: "link", url: "https://github.com/acme/repo/pull/12", text: "View pull request" }, - ]); -}); - -test("posts a card immediately for a new request, even before the run starts", async () => { +test("sets thread status immediately for a new request, even before the run starts", async () => { const sql = createPresentationSql(); const calls = []; const coordinator = createCoordinator(sql, calls, async () => {}); appendEvent(sql, 1, { type: "agent_request", run_id: "run_1", actor: { id: "U1", name: "Ada" }, text: "Fix auth", created_at: "2026-08-25T00:00:00.000Z" }); await coordinator.onEvent(1, { type: "agent_request", run_id: "run_1", actor: { id: "U1", name: "Ada" }, text: "Fix auth", created_at: "2026-08-25T00:00:00.000Z" }); - assert.deepEqual(calls.map((call) => call.method), ["chat.postMessage"]); - assert.match(calls[0].body.text, /Fix auth/); + assert.deepEqual(calls.map((call) => call.method), ["assistant.threads.setStatus"]); + assert.equal(calls[0].body.status, "is starting..."); appendEvent(sql, 2, { type: "agent_request_queued", run_id: "run_1", position: 2 }); await coordinator.onEvent(2, { type: "agent_request_queued", run_id: "run_1", position: 2 }); - assert.deepEqual(calls.map((call) => call.method), ["chat.postMessage", "chat.update"]); - assert.match(JSON.stringify(calls[1].body), /In queue \(position 2\)/); + assert.deepEqual(calls.map((call) => call.method), ["assistant.threads.setStatus", "assistant.threads.setStatus"]); + assert.equal(calls[1].body.status, "is in queue (position 2)..."); }); -test("coalesces live updates, then delivers the final response and deletes the card", async () => { +test("coalesces live updates, then delivers the final response without posting a card", async () => { const sql = createPresentationSql(); const calls = []; const coordinator = createCoordinator(sql, calls, async () => {}); @@ -309,24 +188,23 @@ test("coalesces live updates, then delivers the final response and deletes the c await coordinator.onEvent(2, { type: "phase", phase: "executing", model: "model" }, "run_1"); appendEvent(sql, 3, { type: "status", message: "Running tests" }); await coordinator.onEvent(3, { type: "status", message: "Running tests" }, "run_1"); - assert.deepEqual(calls.map((call) => call.method), ["chat.postMessage"]); + assert.deepEqual(calls.map((call) => call.method), ["assistant.threads.setStatus"]); + assert.equal(calls[0].body.status, "is starting..."); await coordinator.drainDue(Date.now() + 3_000); + assert.equal(calls.at(-1).body.status, "is verifying..."); appendEvent(sql, 4, { type: "agent_response", run_id: "run_1", text: "Done" }); await coordinator.onEvent(4, { type: "agent_response", run_id: "run_1", text: "Done" }); appendEvent(sql, 5, { type: "agent_run_completed", run_id: "run_1" }); await coordinator.onEvent(5, { type: "agent_run_completed", run_id: "run_1" }); const methods = calls.map((call) => call.method); - assert.deepEqual(methods, ["chat.postMessage", "chat.update", "chat.update", "chat.postMessage", "chat.delete"]); - const terminalUpdate = calls[2]; - assert.equal(terminalUpdate.body.blocks[0].subtitle.text, "Completed successfully."); - assert.equal(calls[3].body.text, "Done"); - assert.equal(calls[4].method, "chat.delete"); + assert.deepEqual(methods, ["assistant.threads.setStatus", "assistant.threads.setStatus", "chat.postMessage"]); + assert.equal(calls[2].body.text, "Done"); assert.equal(sql.getRow("run_1"), undefined); }); -test("deletes the card on failure after sending the failure notice", async () => { +test("delivers the failure notice without deleting a card", async () => { const sql = createPresentationSql(); const calls = []; const coordinator = createCoordinator(sql, calls, async () => {}); @@ -335,13 +213,12 @@ test("deletes the card on failure after sending the failure notice", async () => appendEvent(sql, 2, { type: "agent_run_failed", run_id: "run_1", message: "Tests failed" }); await coordinator.onEvent(2, { type: "agent_run_failed", run_id: "run_1", message: "Tests failed" }); - assert.deepEqual(calls.map((call) => call.method), ["chat.postMessage", "chat.update", "chat.postMessage", "chat.delete"]); - assert.equal(calls[1].body.blocks[0].subtitle.text, "Verification failed."); - assert.match(calls[2].body.text, /could not complete the Agent Run/); + assert.deepEqual(calls.map((call) => call.method), ["assistant.threads.setStatus", "chat.postMessage"]); + assert.match(calls[1].body.text, /could not complete the Agent Run/); assert.equal(sql.getRow("run_1"), undefined); }); -test("first live card event tolerates a missing presentation row", async () => { +test("first status event tolerates a missing presentation row", async () => { const sql = createPresentationSql({ strictPresentationRowRead: true }); const calls = []; const coordinator = createCoordinator(sql, calls, async () => {}); @@ -349,7 +226,7 @@ test("first live card event tolerates a missing presentation row", async () => { appendEvent(sql, 1, started); await assert.doesNotReject(() => coordinator.onEvent(1, started)); - assert.deepEqual(calls.map((call) => call.method), ["chat.postMessage"]); + assert.deepEqual(calls.map((call) => call.method), ["assistant.threads.setStatus"]); }); test("keeps one coalescing deadline while activity continues", async () => { @@ -373,140 +250,81 @@ test("keeps one coalescing deadline while activity continues", async () => { appendEvent(sql, 4, { type: "status", message: "Running tests" }); await coordinator.onEvent(4, { type: "status", message: "Running tests" }, "run_1"); - assert.deepEqual(calls.map((call) => call.method), ["chat.postMessage"]); + assert.deepEqual(calls.map((call) => call.method), ["assistant.threads.setStatus"]); await coordinator.drainDue(102_001); - assert.deepEqual(calls.map((call) => call.method), ["chat.postMessage", "chat.update"]); + assert.deepEqual(calls.map((call) => call.method), ["assistant.threads.setStatus", "assistant.threads.setStatus"]); + assert.equal(calls[1].body.status, "is reading files..."); } finally { Date.now = originalNow; } }); -test("keeps a failed update pending and recovers it without creating a second card", async () => { +test("keeps a failed status update pending and recovers it", async () => { const sql = createPresentationSql(); const calls = []; - let updateAttempts = 0; + let statusAttempts = 0; const coordinator = createCoordinator(sql, calls, async () => {}, async (_token, method, body) => { calls.push({ method, body }); - if (method === "chat.update" && updateAttempts++ < 3) return { ok: false, error: "http_503", status: 503 }; - if (method === "chat.postMessage") return { ok: true, data: { ts: "card_1" } }; + if (method === "assistant.threads.setStatus" && statusAttempts++ < 3) return { ok: false, error: "http_503", status: 503 }; return { ok: true, data: { ok: true } }; }); appendEvent(sql, 1, started); await coordinator.onEvent(1, started); - appendEvent(sql, 2, { type: "phase", phase: "executing", model: "model" }); - await coordinator.onEvent(2, { type: "phase", phase: "executing", model: "model" }, "run_1"); - await coordinator.drainDue(Date.now() + 3_000); - assert.equal(calls.filter((call) => call.method === "chat.postMessage").length, 1); - assert.equal(calls.filter((call) => call.method === "chat.update").length, 3); + assert.equal(calls.filter((call) => call.method === "assistant.threads.setStatus").length, 3); await coordinator.drainDue(Date.now() + 10_000); - assert.equal(calls.filter((call) => call.method === "chat.postMessage").length, 1); - assert.equal(calls.filter((call) => call.method === "chat.update").length, 4); + assert.equal(calls.filter((call) => call.method === "assistant.threads.setStatus").length, 4); }); -test("persists an unsupported-card fallback timestamp and updates that message", async () => { +test("stops retrying after a permanent Slack status error", async () => { const sql = createPresentationSql(); const calls = []; const coordinator = createCoordinator(sql, calls, async () => {}, async (_token, method, body) => { calls.push({ method, body }); - if (method === "chat.postMessage" && body.blocks) return { ok: false, error: "invalid_blocks" }; - if (method === "chat.postMessage") return { ok: true, data: { ts: "fallback_1" } }; - if (body.blocks) return { ok: false, error: "invalid_blocks" }; - return { ok: true, data: { ok: true } }; + return { ok: false, error: "method_not_supported_for_channel_type" }; }); - appendEvent(sql, 1, started); await coordinator.onEvent(1, started); - appendEvent(sql, 2, { type: "status", message: "Running tests" }); - await coordinator.onEvent(2, { type: "status", message: "Running tests" }, "run_1"); - await coordinator.drainDue(Date.now() + 3_000); - - assert.equal(sql.getRow("run_1").external_message_id, "fallback_1"); - assert.equal(calls.filter((call) => call.method === "chat.postMessage").length, 2); - assert.equal(calls.filter((call) => call.method === "chat.update").length, 2); - assert.ok(calls.slice(-1)[0].body.ts === "fallback_1"); -}); - -test("retries a failed card deletion via the alarm, then removes the row", async () => { - const sql = createPresentationSql(); - const calls = []; - const alarms = []; - let deleteAttempts = 0; - const coordinator = createCoordinator(sql, calls, async () => {}, async (_token, method, body) => { - calls.push({ method, body }); - if (method === "chat.delete" && deleteAttempts++ < 3) return { ok: false, error: "http_503", status: 503 }; - if (method === "chat.postMessage") return { ok: true, data: { ts: "card_1" } }; - return { ok: true, data: { ok: true } }; - }, alarms); - - appendEvent(sql, 1, started); - await coordinator.onEvent(1, started); - appendEvent(sql, 2, { type: "agent_response", run_id: "run_1", text: "Done" }); - await coordinator.onEvent(2, { type: "agent_response", run_id: "run_1", text: "Done" }); - appendEvent(sql, 3, { type: "agent_run_completed", run_id: "run_1" }); - await coordinator.onEvent(3, { type: "agent_run_completed", run_id: "run_1" }); - - // Response delivered, delete exhausted retries: row survives with a retry. - assert.equal(sql.getRow("run_1").card_delete_pending_at !== null, true); - assert.ok(sql.getRow("run_1").next_retry_at > Date.now()); - assert.ok(alarms.length > 0); - await coordinator.drainDue(Date.now() + 10_000); - assert.equal(calls.filter((call) => call.method === "chat.delete").length, 4); - assert.equal(sql.getRow("run_1"), undefined); + assert.equal(calls.filter((call) => call.method === "assistant.threads.setStatus").length, 1); + assert.equal(sql.getRow("run_1").presentation_status, "uncertain"); }); -test("treats a 404 card delete as already-done and cleans up", async () => { - const sql = createPresentationSql(); - const calls = []; - const coordinator = createCoordinator(sql, calls, async () => {}, async (_token, method, body) => { - calls.push({ method, body }); - if (method === "chat.delete") return { ok: false, error: "message_not_found", status: 404 }; - if (method === "chat.postMessage") return { ok: true, data: { ts: "card_1" } }; - return { ok: true, data: { ok: true } }; - }); - - appendEvent(sql, 1, started); - await coordinator.onEvent(1, started); - appendEvent(sql, 2, { type: "agent_run_completed", run_id: "run_1" }); - await coordinator.onEvent(2, { type: "agent_run_completed", run_id: "run_1" }); - - assert.equal(calls.filter((call) => call.method === "chat.delete").length, 1); - assert.equal(sql.getRow("run_1"), undefined); -}); - -test("replays a pending card teardown after coordinator restart", async () => { +test("heartbeats the same status before Slack's two-minute timeout", async () => { const originalNow = Date.now; - Date.now = () => 200_000; + let now = 100_000; + Date.now = () => now; try { const sql = createPresentationSql(); const calls = []; - const alarms = []; - let deleteAttempts = 0; - const api = async (_token, method, body) => { - calls.push({ method, body }); - if (method === "chat.delete" && deleteAttempts++ < 3) return { ok: false, error: "http_503", status: 503 }; - if (method === "chat.postMessage") return { ok: true, data: { ts: "message_1" } }; - return { ok: true, data: { ok: true } }; - }; - const firstCoordinator = createCoordinator(sql, calls, async () => {}, api, alarms); + const coordinator = createCoordinator(sql, calls, async () => {}); appendEvent(sql, 1, started); - await firstCoordinator.onEvent(1, started); - appendEvent(sql, 2, { type: "agent_run_completed", run_id: "run_1" }); - await firstCoordinator.onEvent(2, { type: "agent_run_completed", run_id: "run_1" }); - assert.ok(sql.getRow("run_1").next_retry_at !== null); - assert.ok(alarms.length > 0); - - const restartedCoordinator = createCoordinator(sql, calls, async () => {}, api, alarms); - await restartedCoordinator.drainDue(210_000); - assert.equal(calls.filter((call) => call.method === "chat.delete").length, 4); - assert.equal(sql.getRow("run_1"), undefined); + await coordinator.onEvent(1, started); + assert.equal(calls.length, 1); + + now += 90_000; + await coordinator.drainDue(now); + assert.equal(calls.length, 2); + assert.equal(calls[1].method, "assistant.threads.setStatus"); + assert.equal(calls[1].body.status, "is starting..."); } finally { Date.now = originalNow; } }); -test("queued turns surface live progress after they start, each card resolves independently", async () => { +test("does not set status when waiting for a question", async () => { + const sql = createPresentationSql(); + const calls = []; + const coordinator = createCoordinator(sql, calls, async () => {}); + appendEvent(sql, 1, started); + await coordinator.onEvent(1, started); + appendEvent(sql, 2, { type: "question_raised", request_id: "q1", run_id: "run_1", question: "Which region?", allow_freeform: false, allow_multiple: false, answerable_by: "anyone", status: "open", raised_at: "2026-08-13T00:00:00.000Z" }); + await coordinator.onEvent(2, { type: "question_raised", request_id: "q1", run_id: "run_1", question: "Which region?", allow_freeform: false, allow_multiple: false, answerable_by: "anyone", status: "open", raised_at: "2026-08-13T00:00:00.000Z" }); + assert.deepEqual(calls.map((call) => call.method), ["assistant.threads.setStatus"]); + assert.equal(sql.getRow("run_1").last_render_fingerprint, ""); +}); + +test("queued turns surface live progress after they start, each status resolves independently", async () => { const sql = createPresentationSql(); const calls = []; const coordinator = createCoordinator(sql, calls, async () => {}); @@ -530,8 +348,7 @@ test("queued turns surface live progress after they start, each card resolves in await append(req(3, "Third task"), "run_1"); await append(queued(3), "run_1"); - // Cards posted for all three turns before any run starts. - assert.equal(calls.filter((call) => call.method === "chat.postMessage").length, 3); + assert.equal(calls.filter((call) => call.method === "assistant.threads.setStatus").length, 6); await append({ type: "agent_run_completed", run_id: "run_1" }, "run_1"); await append(startedR(2, "Second task"), "run_2"); @@ -539,28 +356,22 @@ test("queued turns surface live progress after they start, each card resolves in await append({ type: "agent_event", event: { type: "tool_execution_start", tool: "edit", toolCallId: "c2", args: { file_path: "src/a.ts" } } }, "run_2"); await coordinator.drainDue(Date.now() + 3_000); - // Run 2's card now shows live progress (not stale queue state) and the - // title of its own request. - const cardUpdates = calls.filter((call) => call.method === "chat.update"); - const run2Updates = cardUpdates.filter((call) => JSON.stringify(call.body).includes("Second task")); - const run2LiveUpdate = run2Updates.at(-1); - assert.ok(run2LiveUpdate, "run 2 card should have a live update"); - assert.match(JSON.stringify(run2LiveUpdate.body), /Editing code/); - assert.match(JSON.stringify(run2LiveUpdate.body), /login\.ts|a\.ts/); - assert.doesNotMatch(JSON.stringify(run2LiveUpdate.body), /In queue/); - assert.doesNotMatch(JSON.stringify(run2LiveUpdate.body), /First task/); + const statusUpdates = calls.filter((call) => call.method === "assistant.threads.setStatus"); + const run2Live = statusUpdates.filter((call) => call.body.status.includes("editing code")).at(-1); + assert.ok(run2Live, "run 2 should have a live status"); + assert.match(run2Live.body.status, /a\.ts/); + assert.doesNotMatch(run2Live.body.status, /queue/); await append({ type: "agent_response", run_id: "run_2", text: "Second done" }, "run_2"); await append({ type: "agent_run_completed", run_id: "run_2" }, "run_2"); - assert.equal(calls.filter((call) => call.method === "chat.delete").length, 2); + assert.equal(calls.filter((call) => call.method === "chat.delete").length, 0); assert.equal(sql.getRow("run_1"), undefined); assert.equal(sql.getRow("run_2"), undefined); assert.notEqual(sql.getRow("run_3"), undefined); - assert.equal(sql.getRow("run_3").card_delete_pending_at, null); }); -test("never folds another run's live progress into a queued card", async () => { +test("never folds another run's live progress into a queued status", async () => { const sql = createPresentationSql(); const calls = []; const coordinator = createCoordinator(sql, calls, async () => {}); @@ -578,61 +389,36 @@ test("never folds another run's live progress into a queued card", async () => { await append(req(1, "First task"), "run_1"); await append(startedR(1, "First task"), "run_1"); - // run_2 queued while run_1 executes... await append(req(2, "Second task"), "run_1"); await append(queued(2, 1), "run_1"); - // ...and run_1 keeps producing global progress after run_2 was queued. await append({ type: "status", message: "Running tests" }, "run_1"); await append({ type: "agent_event", event: { type: "tool_execution_start", tool: "read", toolCallId: "c1", args: { file_path: "src/lib.ts" } } }, "run_1"); await coordinator.drainDue(Date.now() + 3_000); - const run2Updates = calls.filter((call) => call.method === "chat.update" && JSON.stringify(call.body).includes("Second task")); - const run2Card = run2Updates.at(-1); - assert.ok(run2Card, "run 2 should have a queued card update"); - assert.match(JSON.stringify(run2Card.body), /In queue \(position 1\)/); - assert.doesNotMatch(JSON.stringify(run2Card.body), /Verifying|Investigating|Reading files|lib\.ts/); + const queuedStatuses = calls.filter((call) => call.method === "assistant.threads.setStatus" && call.body.status.includes("queue")); + assert.ok(queuedStatuses.at(-1)); + assert.equal(queuedStatuses.at(-1).body.status, "is in queue (position 1)..."); + const run2Fingerprints = sql.getRow("run_2").last_render_fingerprint; + assert.equal(run2Fingerprints, "is in queue (position 1)..."); }); -test("teardown stays scheduled across a restart mid-delete", async () => { +test("retries a lost initial status after a network error", async () => { const sql = createPresentationSql(); const calls = []; - const alarms = []; - let deleteAttempts = 0; const api = async (_token, method, body) => { calls.push({ method, body }); - if (method === "chat.delete" && deleteAttempts++ < 3) return { ok: false, error: "http_503", status: 503 }; - if (method === "chat.postMessage") return { ok: true, data: { ts: "card_1" } }; - return { ok: true, data: { ok: true } }; + return { ok: false, error: "network_error" }; }; - const firstCoordinator = createCoordinator(sql, calls, async () => {}, api, alarms); appendEvent(sql, 1, started); - await firstCoordinator.onEvent(1, started); - appendEvent(sql, 2, { type: "agent_response", run_id: "run_1", text: "Done" }); - await firstCoordinator.onEvent(2, { type: "agent_response", run_id: "run_1", text: "Done" }); - appendEvent(sql, 3, { type: "agent_run_completed", run_id: "run_1" }); - await firstCoordinator.onEvent(3, { type: "agent_run_completed", run_id: "run_1" }); - - // Response delivered; delete failing but the row stays scheduled (due - // immediately at teardown, re-armed on exhaustion), so even a hard restart - // cannot strand the card. - const row = sql.getRow("run_1"); - assert.equal(row.pending_final_response_cursor, null); - assert.notEqual(row.card_delete_pending_at, null); - assert.ok(row.next_retry_at !== null); - assert.ok(alarms.length > 0); - - const restartedCoordinator = createCoordinator(sql, calls, async () => {}, api, alarms); - const retryAt = restartedCoordinator.nextRetryAt(); - assert.ok(retryAt !== null); - await restartedCoordinator.drainDue(Date.now() + 10_000); - assert.equal(sql.getRow("run_1"), undefined); + const coordinator = createCoordinator(sql, calls, async () => {}, api); + await coordinator.onEvent(1, started); + await coordinator.drainDue(Date.now() + 100_000); + assert.ok(calls.filter((call) => call.method === "assistant.threads.setStatus").length > 1); }); function createCoordinator(sql, calls, sleep, api = async (_token, method, body) => { calls.push({ method, body }); - return method === "chat.postMessage" - ? { ok: true, data: { ts: "card_1" } } - : { ok: true, data: { ok: true } }; + return { ok: true, data: method === "chat.postMessage" ? { ts: "msg_1" } : { ok: true } }; }, alarms = []) { const env = { DB: fakeD1(), diff --git a/packages/worker/test/slack-client.test.mjs b/packages/worker/test/slack-client.test.mjs index 53c1efe..210d47e 100644 --- a/packages/worker/test/slack-client.test.mjs +++ b/packages/worker/test/slack-client.test.mjs @@ -185,6 +185,30 @@ test("fetchSlackThreadReplies requests the Slack thread", async () => { assert.deepEqual(result, { ok: true, data: { messages: [] } }); }); +test("Slack client helpers set assistant thread status", async () => { + const calls = []; + const api = async (token, method, body) => { + calls.push({ token, method, body }); + return { ok: true, data: { ok: true } }; + }; + + await slackClient.setSlackThreadStatus(api, "xoxb-test", { + channelId: "C123", + threadTs: "171951.0001", + status: "is reading files...", + }); + + assert.deepEqual(calls, [{ + token: "xoxb-test", + method: "assistant.threads.setStatus", + body: { + channel_id: "C123", + thread_ts: "171951.0001", + status: "is reading files...", + }, + }]); +}); + test("Slack client helpers update, notify, and resolve users", async () => { assert.equal(typeof slackClient.updateSlackMessage, "function"); assert.equal(typeof slackClient.postSlackEphemeral, "function"); diff --git a/packages/worker/test/slack-render.test.mjs b/packages/worker/test/slack-render.test.mjs index dbc4675..6748a3c 100644 --- a/packages/worker/test/slack-render.test.mjs +++ b/packages/worker/test/slack-render.test.mjs @@ -6,30 +6,10 @@ import * as slackRender from "../dist/integrations/slack/render.js"; const { renderSlackFreeformAnswerModal, renderSlackNotification, - renderSlackRunCard, } = slackRender; const sessionUrl = "https://codevil.example/sessions/ses_123"; -test("renderSlackRunCard keeps validated sources clickable in rich text", () => { - const rendered = renderSlackRunCard({ - runId: "run_1", - title: "Ship", - status: "complete", - phase: "Complete", - summary: "Completed successfully.", - steps: [], - droppedSteps: 0, - prUrl: "https://github.com/acme/app/pull/12", - }, sessionUrl, 1); - - assert.deepEqual(rendered.blocks[0].child_blocks.at(-1).elements[0].elements, [ - { type: "link", url: sessionUrl, text: "Open Codevil" }, - { type: "text", text: " ยท " }, - { type: "link", url: "https://github.com/acme/app/pull/12", text: "View pull request" }, - ]); -}); - test("renderSlackNotification renders conversational messages", () => { assert.deepEqual( renderSlackNotification({ From ade0485c1c27c1d6cfd7ce770f2ac7a35494f55f Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Sun, 6 Sep 2026 18:22:59 +0530 Subject: [PATCH 12/12] fix(slack): remove unresolved notification delivery hook --- .../worker/src/integrations/slack/live-run-card.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/worker/src/integrations/slack/live-run-card.ts b/packages/worker/src/integrations/slack/live-run-card.ts index 882fa48..4f7fa41 100644 --- a/packages/worker/src/integrations/slack/live-run-card.ts +++ b/packages/worker/src/integrations/slack/live-run-card.ts @@ -1,4 +1,3 @@ -import type { DeliveryState } from "../notification-delivery.js"; import type { DOToCLIEvent } from "@codevil/shared"; import type { Env } from "../../orchestrator/types.js"; import { redactEvent } from "../../redaction.js"; @@ -53,7 +52,6 @@ export class LiveRunCardCoordinator { private readonly scheduleAlarm: (when: number) => void, api?: SlackApi, sleep: (delayMs: number) => Promise = sleepFor, - private readonly deliverNotification?: (cursor: number, event: DOToCLIEvent) => DeliveryState, ) { this.api = api ?? createSlackWebApi(); this.sleep = sleep; @@ -261,13 +259,7 @@ export class LiveRunCardCoordinator { : terminal.event.type === "agent_run_failed" ? terminal.event : { type: "agent_response", run_id: row.run_id, text: `Completed.${presentation.prUrl ? ` Draft PR: ${presentation.prUrl}` : ""}` } as DOToCLIEvent; - const durable = this.deliverNotification?.(response?.cursor ?? terminal.cursor, event); - if (durable && durable.status !== "delivered") { - this.upsert({ ...row, next_retry_at: durable.nextRetryAt, updated_at: new Date().toISOString() }); - if (durable.nextRetryAt !== null) this.scheduleAlarm(Math.max(durable.nextRetryAt, Date.now() + 1)); - return; - } - const delivered = durable?.status === "delivered" || await notifyExternalConversation({ + const delivered = await notifyExternalConversation({ env: this.env, sessionId: this.sessionId(), workerOrigin: this.workerOrigin(),