diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bc6707..4109f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ release gates. Stable promotion and publication require approval of the exact te ### Beta development notes +- Keep Claude shell-task lifecycle updates out of subagent progress rows and the Coordinating + agents label, while preserving ordinary tool progress and genuine agent activity. + - Preserve parsed container background-job exit codes in the structured completion audit, while keeping completion messages in plain language and unconfirmed outcomes unknown. diff --git a/FEATURES.md b/FEATURES.md index b2125b4..e147a3f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -183,6 +183,12 @@ Codex estimates include GPT-6.1 Terra ($1 / $0.05 / $5 per million input / cache Slack’s native running/Stop label shows the resolved model and explicitly configured effort, plus compact live phases (Thinking, Using tools, Writing, Waiting, Queued, Coordinating agents). Engine fallback replaces both model and effort; unset effort is omitted rather than guessed. +Claude task lifecycle messages distinguish shell work from subagents: explicit non-agent tasks, +including foreground and background `local_bash`, never create agent activity rows or the +Coordinating agents phase. Their later untyped lifecycle messages retain that classification, +including tool-use aliases. Ordinary Bash tool events and genuine local/remote Agent or legacy +Task progress remain visible; older agent streams without optional task-type metadata still work. + ## Optional password and Slack code sign-in - Settings → Admin access can enable an alternative to passkeys: the configured admin password diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 9fbf5ed..82df9c0 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,24 @@ # ChannelGate — Test Plan +## Shell tasks versus subagent progress (2026-11-06) + +Run `node --test test/progress-report.test.js test/slack-progress.test.js test/codex-message-to-reply-e2e.test.js`. +Feed the actual Claude parser foreground/background `local_bash` starts followed by untyped +progress, updates, notifications and late tool-use aliases. No frame may become agent activity; +ordinary Bash tool use/results remain visible. Interleave real `local_agent`/`remote_agent` +lifecycle frames and retain their stable identities, elapsed tool progress and terminal states. +Retain Agent/legacy Task launches and older agent messages without optional task-type metadata. +Through the actual Slack progress consumer, shell work stays Using tools and genuine agents +produce Coordinating agents; Codex command execution remains a negative control. + +Live acceptance on an activated candidate must observe a harmless foreground shell wait and +genuine in-turn subagent work separately in each engine, with exact native status/card evidence, +tool traces and final delivery. The Claude-only background-task protocol variant is isolated; +do not start harness background work in production solely for this check. The confirmed parser +defect follows the official SDK task-type contract; a prior live Bash-only misleading label is +corroborating presentation evidence, not proof of its unretained raw event shape or loaded +revision. Isolated parser/status tests do not establish live Slack rendering or deployed acceptance. + ## Readable background continuation errors (2026-11-06) `node --test test/background-continuation-errors.test.js test/process-outcome.test.js` diff --git a/src/engines/stream.js b/src/engines/stream.js index 09dc430..5d6cdd5 100644 --- a/src/engines/stream.js +++ b/src/engines/stream.js @@ -174,6 +174,7 @@ export function createStreamConsumer({ onDelta = null, onEvent = null } = {}) { const thinkingBlocks = new Map(); // content-block index → { buf, emittedAt, last } const taskAliases = new Map(); // Claude task_id → originating Agent/Task tool_use_id const agentIds = new Set(); // stable ids known to represent native subagents + const nonAgentTaskIds = new Set(); // explicit Bash/other task identities, including later aliases const seenToolUses = new Set(); let text = ""; let initialModel = ""; @@ -211,8 +212,20 @@ export function createStreamConsumer({ onDelta = null, onEvent = null } = {}) { }; const emitClaudeTask = (p) => { const subtype = String(p?.subtype || ""); + if (p?.type !== "tool_progress" && (p?.type !== "system" || !subtype.startsWith("task_"))) return false; + const id = taskIdFor(p) || (p?.type === "tool_progress" + ? String(p.parent_tool_use_id || p.parentToolUseId || "").trim() : ""); + const identities = [id, p.task_id, p.taskId, p.tool_use_id, p.toolUseId].filter(Boolean).map(String); + const taskType = String(p.task_type || p.taskType || "").trim(); + // SDK task lifecycles also describe foreground/background Bash (local_bash). + // Remember that classification: later progress/terminal frames omit task_type and may + // introduce a tool-use alias. Missing optional metadata still supports older agent streams. + if ((taskType && taskType !== "local_agent" && taskType !== "remote_agent") + || identities.some((identity) => nonAgentTaskIds.has(identity))) { + for (const identity of identities) nonAgentTaskIds.add(identity); + return true; + } if (p?.type === "tool_progress") { - const id = taskIdFor(p) || String(p.parent_tool_use_id || p.parentToolUseId || "").trim(); if (!id || !agentIds.has(id)) return false; const seconds = p.elapsed_time_seconds ?? p.elapsedTimeSeconds; onEvent?.({ @@ -225,9 +238,6 @@ export function createStreamConsumer({ onDelta = null, onEvent = null } = {}) { }); return true; } - if (p?.type !== "system" || !subtype.startsWith("task_")) return false; - - const id = taskIdFor(p); if (!id) return true; agentIds.add(id); const usage = usageFields(p.usage); diff --git a/test/progress-report.test.js b/test/progress-report.test.js index 5a0427c..865fd91 100644 --- a/test/progress-report.test.js +++ b/test/progress-report.test.js @@ -430,3 +430,77 @@ test("Claude stream maps task failure and tool progress without requiring option }, ]); }); + +test("Claude Bash task lifecycles never become subagent activity", () => { + for (const is_backgrounded of [false, true]) { + const events = []; + const consumer = createStreamConsumer({ onEvent: (event) => events.push(event) }); + consumer.consume({ type: "stream_event", event: { + type: "content_block_start", index: 0, + content_block: { type: "tool_use", id: "bash-tool", name: "Bash" }, + } }); + consumer.consume({ type: "stream_event", event: { + type: "content_block_delta", index: 0, + delta: { type: "input_json_delta", partial_json: '{"command":"sleep 1"}' }, + } }); + consumer.consume({ type: "stream_event", event: { type: "content_block_stop", index: 0 } }); + consumer.consume({ type: "system", subtype: "task_started", task_id: "bash-task", + task_type: "local_bash", is_backgrounded, description: "Wait briefly" }); + // Later frames may omit the type and introduce the originating tool-use alias. + consumer.consume({ type: "system", subtype: "task_progress", task_id: "bash-task", + tool_use_id: "bash-tool", description: "Still waiting", usage: { duration_ms: 500 } }); + consumer.consume({ type: "tool_progress", tool_use_id: "bash-tool", + tool_name: "Bash", elapsed_time_seconds: 0.5 }); + consumer.consume({ type: "system", subtype: "task_updated", task_id: "bash-task", + patch: { status: "completed" } }); + consumer.consume({ type: "system", subtype: "task_notification", task_id: "bash-task", + status: "completed", summary: "Done" }); + consumer.consume({ type: "user", message: { content: [ + { type: "tool_result", tool_use_id: "bash-tool", content: "Done", is_error: false }, + ] } }); + assert.equal(events.some((event) => event.kind === "agent_activity"), false); + assert.ok(events.some((event) => event.kind === "tool_use" && event.name === "Bash")); + assert.ok(events.some((event) => event.kind === "tool_result" && event.id === "bash-tool")); + } +}); + +test("Claude explicit agent tasks retain progress while interleaved non-agent tasks stay separate", () => { + for (const task_type of ["local_agent", "remote_agent"]) { + const events = []; + const consumer = createStreamConsumer({ onEvent: (event) => events.push(event) }); + consumer.consume({ type: "system", subtype: "task_started", task_id: "child", + tool_use_id: "agent-tool", task_type, description: "Research" }); + consumer.consume({ type: "system", subtype: "task_started", task_id: "shell", + tool_use_id: "shell-tool", task_type: "local_bash", parent_task_id: "child" }); + consumer.consume({ type: "system", subtype: "task_progress", task_id: "shell" }); + consumer.consume({ type: "tool_progress", task_id: "child", tool_name: "Read", elapsed_time_seconds: 2 }); + consumer.consume({ type: "system", subtype: "task_notification", task_id: "shell", status: "completed" }); + consumer.consume({ type: "system", subtype: "task_notification", task_id: "child", status: "completed" }); + const activity = events.filter((event) => event.kind === "agent_activity"); + assert.deepEqual(activity.map((event) => [event.id, event.status]), [ + ["agent-tool", "running"], ["agent-tool", "running"], ["agent-tool", "completed"], + ]); + assert.equal(activity[1].elapsedMs, 2000); + } +}); + +test("Claude Agent and legacy Task aliases retain untyped lifecycle updates", () => { + for (const name of ["Agent", "Task"]) { + const events = []; + const consumer = createStreamConsumer({ onEvent: (event) => events.push(event) }); + consumer.consume({ type: "stream_event", event: { type: "content_block_start", index: 0, + content_block: { type: "tool_use", id: "legacy-tool", name } } }); + consumer.consume({ type: "stream_event", event: { type: "content_block_delta", index: 0, + delta: { type: "input_json_delta", partial_json: '{"description":"Check files"}' } } }); + consumer.consume({ type: "stream_event", event: { type: "content_block_stop", index: 0 } }); + consumer.consume({ type: "system", subtype: "task_started", task_id: "legacy-task", + tool_use_id: "legacy-tool" }); + consumer.consume({ type: "tool_progress", task_id: "legacy-task", elapsed_time_seconds: 1 }); + consumer.consume({ type: "system", subtype: "task_updated", task_id: "legacy-task", + patch: { status: "completed" } }); + assert.deepEqual(events.map((event) => [event.kind, event.id, event.status]), [ + ["agent_activity", "legacy-tool", "running"], ["agent_activity", "legacy-tool", "running"], + ["agent_activity", "legacy-tool", "running"], ["agent_activity", "legacy-tool", "completed"], + ]); + } +}); diff --git a/test/slack-progress.test.js b/test/slack-progress.test.js index 539dbc0..b74f238 100644 --- a/test/slack-progress.test.js +++ b/test/slack-progress.test.js @@ -3131,6 +3131,48 @@ test("native Stop label carries resolved effort and live phases, then clears act assert.equal(native.at(-1).username, "Opus 4.8 (high)"); }); +test("parsed shell tasks keep native tool status while real subagents coordinate", async () => { + const { createStreamConsumer } = await import("../src/engines/stream.js"); + for (const engine of ["claude", "codex"]) { + const calls = []; + const client = { + apiCall: async (method, payload) => calls.push([method, payload]), + chatStream: () => ({ append: async () => {}, stop: async () => {} }), + chat: { postMessage: async () => {} }, + }; + const progress = startProgress("stream", client, `C_TASK_TYPE_${engine}`, "111.223", + { authorId: "U1", teamId: "T1" }); + progress.onRuntimeResolved({ engine, model: engine === "claude" ? "sonnet" : "gpt-6.1-sol" }); + const consumer = createStreamConsumer({ onEvent: progress.onEvent }); + try { + if (engine === "claude") { + consumer.consume({ type: "stream_event", event: { type: "content_block_start", index: 0, + content_block: { type: "tool_use", id: "shell", name: "Bash" } } }); + consumer.consume({ type: "stream_event", event: { type: "content_block_stop", index: 0 } }); + consumer.consume({ type: "system", subtype: "task_started", task_id: "shell-task", + tool_use_id: "shell", task_type: "local_bash", is_backgrounded: false }); + consumer.consume({ type: "system", subtype: "task_progress", task_id: "shell-task" }); + } else { + progress.onEvent(progressFromCodexEvent({ type: "item.started", + item: { id: "shell", type: "command_execution", command: "sleep 1" } }).event); + } + await cardReady(); + const native = () => calls.filter(([method]) => method === "agents.sessions.setStatus") + .map(([, payload]) => payload.username || ""); + assert.ok(native().some((name) => name.includes("Using tools"))); + assert.ok(native().every((name) => !name.includes("Coordinating agents"))); + if (engine === "claude") { + consumer.consume({ type: "system", subtype: "task_started", task_id: "research", + task_type: "local_agent", description: "Research files" }); + await cardReady(); + assert.ok(native().some((name) => name.includes("Coordinating agents"))); + } + } finally { + await progress.stop(); + } + } +}); + test("failed stream fallback clears native processing after delivery", async () => { let actual = "active"; const calls = [];