Skip to content

Track and display sub-agent task updates in progress messages#19

Open
Innei wants to merge 3 commits into
mainfrom
claude/slack-subagent-display-CjhAX
Open

Track and display sub-agent task updates in progress messages#19
Innei wants to merge 3 commits into
mainfrom
claude/slack-subagent-display-CjhAX

Conversation

@Innei

@Innei Innei commented Apr 7, 2026

Copy link
Copy Markdown
Owner

Summary

Add support for tracking and displaying sub-agent task updates in Slack progress messages. When a sub-agent reports task progress via task-update events, these are now captured and rendered in the progress message UI with status indicators and details.

Key Changes

  • Task tracking in activity-sink: Added activeTasks Map and terminalTaskIds Set to track in-progress and completed tasks across event cycles
  • Immediate task-update handling: When a task-update event is received, the task is stored and an immediate progress message update is triggered if the progress message is already active
  • Task lifecycle management:
    • Tasks with terminal status (complete or error) are marked for pruning
    • Pruning occurs on the next activity-state event, allowing completed tasks to briefly display before disappearing
    • All tasks are cleared when an assistant-message is sent
  • Progress message rendering: Updated SlackRenderer to format and display tracked tasks with:
    • Status icons (:hourglass:, :spinner:, :white_check_mark:, :x:)
    • Task titles and optional details
    • Truncation of long details (80 char limit)
    • Tasks displayed as a separate context block in the message
  • Type exports: Exported TrackedTask interface from slack-renderer.ts for use in activity-sink

Implementation Details

  • Task updates trigger immediate UI updates only if a progress message is already active, avoiding unnecessary message creation
  • The deferred pruning strategy ensures users see task completion status briefly before it disappears
  • Multiple concurrent tasks are supported and displayed together
  • Task details are normalized (whitespace collapsed) and truncated to prevent message bloat

https://claude.ai/code/session_01MVwMrkws5FMKUHsoUiqsEi

…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
Copilot AI review requested due to automatic review settings April 7, 2026 16:27
@safedep

safedep Bot commented Apr 7, 2026

Copy link
Copy Markdown

SafeDep Report Summary

Green Malicious Packages Badge Green Vulnerable Packages Badge Green Risky License Badge

No dependency changes detected. Nothing to scan.

View complete scan results →

This report is generated by SafeDep Github App

claude added 2 commits April 7, 2026 16:30
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-sink and 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.

Comment on lines 196 to 202
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;

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +271 to +280
// 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,
);

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +652 to +681
// 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...',
});

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
const afterPrune = upsert.mock.calls.at(-1)![3] as { tasks?: Map<string, unknown> };
expect(afterPrune.tasks).toBeUndefined();
});

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();
});

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants