Track and display sub-agent task updates in progress messages#19
Conversation
…ages Previously task-update events from the Claude Agent SDK were silently ignored. Now we track sub-agent tasks in the activity sink and render them as status lines (with spinner/check/error icons) inside the in-thread progress message block, giving users real-time visibility into parallel sub-agent work. https://claude.ai/code/session_01MVwMrkws5FMKUHsoUiqsEi
SafeDep Report SummaryNo dependency changes detected. Nothing to scan. This report is generated by SafeDep Github App |
Verifies that progress messages include task status icons when sub-agents/background tasks are active, and that the progress message lifecycle (post → update → finalize) works correctly. https://claude.ai/code/session_01MVwMrkws5FMKUHsoUiqsEi
Switch from Slack emoji shortcodes to Unicode checkboxes: ☐ (pending/in_progress), ☑ (complete), ☒ (error) https://claude.ai/code/session_01MVwMrkws5FMKUHsoUiqsEi
There was a problem hiding this comment.
Pull request overview
Adds end-to-end support for receiving sub-agent task-update events, tracking active/terminal tasks in the Slack ingress layer, and rendering them into Slack progress messages so users can see concurrent sub-agent work as it happens.
Changes:
- Track sub-agent tasks in
activity-sinkand pass them into progress-message renderer state (with deferred pruning of terminal tasks). - Render tracked tasks in Slack progress messages with status icons and truncated details.
- Add unit tests covering basic task tracking, pruning, clearing on assistant reply, and multiple tasks.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/slack/ingress/activity-sink.ts |
Tracks task-update events, triggers immediate progress refreshes, and prunes terminal tasks on subsequent activity-state. |
src/slack/render/slack-renderer.ts |
Adds TrackedTask type and renders tasks into progress message text/blocks with status icons and detail truncation. |
tests/activity-sink.test.ts |
Adds test coverage for task tracking lifecycle behavior in the activity sink. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const handleActivityState = async (state: AgentActivityState): Promise<void> => { | ||
| // Prune tasks that completed in a previous cycle so they show briefly before disappearing | ||
| pruneTerminalTasks(); | ||
|
|
||
| const nextStateKey = JSON.stringify(state); | ||
| if (nextStateKey === lastStateKey) return; | ||
| if (nextStateKey === lastStateKey && activeTasks.size === 0) return; | ||
| lastStateKey = nextStateKey; |
There was a problem hiding this comment.
Pruning terminal tasks can fail to update the Slack progress message when the incoming activity-state is identical to the previous one. pruneTerminalTasks() runs before the nextStateKey === lastStateKey guard; if pruning removes the last task, activeTasks.size becomes 0 and the function returns early, leaving completed tasks displayed indefinitely until some other state change occurs. Consider tracking whether tasks were pruned (or including a tasks key/hash in the state key) and forcing an update when the task set changes, even if the activity-state payload is unchanged.
| // If progress message is already active, push an immediate update | ||
| if (progressMessageActive) { | ||
| const state: AgentActivityState = { threadTs, clear: false }; | ||
| progressMessageTs = await renderer.upsertThreadProgressMessage( | ||
| client, | ||
| channel, | ||
| threadTs, | ||
| toRendererState(state), | ||
| progressMessageTs, | ||
| ); |
There was a problem hiding this comment.
The immediate task-update refresh uses AgentActivityState = { threadTs, clear: false }, which drops the last known status/activities/composing flags. Because the renderer defaults missing status to DEFAULT_PROGRESS_STATUS, task updates can temporarily overwrite an active progress message (e.g. “Reading files…”) with the generic default until the next activity-state arrives. Consider caching the most recent non-clear activity-state (or the last renderer state) and reusing it here so task updates only add/refresh tasks without regressing the other UI fields.
| // Activate progress message first | ||
| await sink.onEvent({ | ||
| type: 'activity-state', | ||
| state: { | ||
| threadTs: 'ts1', | ||
| status: 'Reading files...', | ||
| activities: ['Reading src/index.ts...'], | ||
| clear: false, | ||
| }, | ||
| }); | ||
|
|
||
| // Fire a task-update | ||
| await sink.onEvent({ | ||
| type: 'task-update', | ||
| taskId: 'task-1', | ||
| title: 'Researching API docs', | ||
| status: 'in_progress', | ||
| details: 'Reading openapi.yaml...', | ||
| }); | ||
|
|
||
| // The task-update should trigger an immediate progress update | ||
| const lastCall = upsert.mock.calls.at(-1); | ||
| expect(lastCall).toBeDefined(); | ||
| const rendererState = lastCall![3] as { tasks?: Map<string, unknown> }; | ||
| expect(rendererState.tasks).toBeInstanceOf(Map); | ||
| expect(rendererState.tasks!.get('task-1')).toEqual({ | ||
| title: 'Researching API docs', | ||
| status: 'in_progress', | ||
| details: 'Reading openapi.yaml...', | ||
| }); |
There was a problem hiding this comment.
This test asserts the tasks Map is passed to upsertThreadProgressMessage, but it does not assert that the existing progress status/loadingMessages are preserved on a task-update. Given the current implementation builds a minimal state for the immediate refresh, adding an assertion for status (and/or a regression test) would catch accidental resets of the progress message content during task updates.
| const afterPrune = upsert.mock.calls.at(-1)![3] as { tasks?: Map<string, unknown> }; | ||
| expect(afterPrune.tasks).toBeUndefined(); | ||
| }); | ||
|
|
There was a problem hiding this comment.
The pruning test only covers the case where the next activity-state differs (status changes to “Editing…”). There is an edge case where the next activity-state is identical to the previous one; pruning can be skipped by the nextStateKey === lastStateKey early return unless task changes are accounted for. Adding a test that sends an identical activity-state after completing a task would prevent regressions here.
| it('prunes completed tasks on next activity-state cycle even when the state is identical', async () => { | |
| const renderer = createRendererStub(); | |
| const upsert = vi.mocked(renderer.upsertThreadProgressMessage); | |
| const sink = createActivitySink({ | |
| channel: 'C123', | |
| client: createMockClient(), | |
| logger: createTestLogger(), | |
| renderer, | |
| sessionStore: createMockSessionStore(), | |
| threadTs: 'ts1', | |
| }); | |
| // Activate progress | |
| await sink.onEvent({ | |
| type: 'activity-state', | |
| state: { | |
| threadTs: 'ts1', | |
| status: 'Working...', | |
| activities: ['Reading foo...'], | |
| clear: false, | |
| }, | |
| }); | |
| // Start then complete a task | |
| await sink.onEvent({ | |
| type: 'task-update', | |
| taskId: 'task-1', | |
| title: 'Analyze code', | |
| status: 'in_progress', | |
| }); | |
| await sink.onEvent({ | |
| type: 'task-update', | |
| taskId: 'task-1', | |
| title: 'Analyze code', | |
| status: 'complete', | |
| }); | |
| // The completed task should still be visible in the immediate update | |
| const afterComplete = upsert.mock.calls.at(-1)![3] as { tasks?: Map<string, unknown> }; | |
| expect(afterComplete.tasks!.get('task-1')).toEqual( | |
| expect.objectContaining({ status: 'complete' }), | |
| ); | |
| // On the next identical activity-state, completed tasks should still be pruned | |
| await sink.onEvent({ | |
| type: 'activity-state', | |
| state: { | |
| threadTs: 'ts1', | |
| status: 'Working...', | |
| activities: ['Reading foo...'], | |
| clear: false, | |
| }, | |
| }); | |
| const afterPrune = upsert.mock.calls.at(-1)![3] as { tasks?: Map<string, unknown> }; | |
| expect(afterPrune.tasks).toBeUndefined(); | |
| }); |



Summary
Add support for tracking and displaying sub-agent task updates in Slack progress messages. When a sub-agent reports task progress via
task-updateevents, these are now captured and rendered in the progress message UI with status indicators and details.Key Changes
activeTasksMap andterminalTaskIdsSet to track in-progress and completed tasks across event cyclestask-updateevent is received, the task is stored and an immediate progress message update is triggered if the progress message is already activecompleteorerror) are marked for pruningactivity-stateevent, allowing completed tasks to briefly display before disappearingassistant-messageis sentSlackRendererto format and display tracked tasks with::hourglass:,:spinner:,:white_check_mark:,:x:)TrackedTaskinterface fromslack-renderer.tsfor use in activity-sinkImplementation Details
https://claude.ai/code/session_01MVwMrkws5FMKUHsoUiqsEi