Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 6 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
18 changes: 14 additions & 4 deletions src/engines/stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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?.({
Expand All @@ -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);
Expand Down
74 changes: 74 additions & 0 deletions test/progress-report.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
]);
}
});
42 changes: 42 additions & 0 deletions test/slack-progress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
Loading