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
74 changes: 74 additions & 0 deletions apps/server/src/orchestration/subagentProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { EventId, TurnId, type OrchestrationThreadActivity } from "@threadlines/
import { projectSubagentActivity } from "./subagentProjection.ts";

const TURN_ID = TurnId.make("11111111-1111-4111-8111-111111111111");
const RESUME_TURN_ID = TurnId.make("22222222-2222-4222-8222-222222222222");
const SPAWN_TOOL_USE_ID = "toolu_01GSFNVFM8ppotb3KXjK3ASy";

function activity(input: {
Expand Down Expand Up @@ -207,6 +208,79 @@ describe("projectSubagentActivity", () => {
expect(unchanged).toHaveLength(1);
});

it("re-opens a settled agent whose task starts again under the resuming call", () => {
const spawned = projectSubagentActivity(
[],
claudeSpawnActivity({
id: "a1",
kind: "tool.started",
status: "inProgress",
turnId: TURN_ID,
}),
);
const settled = projectSubagentActivity(
spawned,
activity({
id: "a2",
kind: "task.completed",
payload: { taskId: "a34bb18edee269135", status: "completed", toolUseId: SPAWN_TOOL_USE_ID },
createdAt: "2026-08-15T00:05:00.000Z",
}),
);
expect(settled[0]?.status).toBe("completed");

// The model sent the agent another message: same task, but reported under
// the call that resumed it and inside the turn that sent it.
const resumed = projectSubagentActivity(
settled,
activity({
id: "a3",
kind: "task.started",
turnId: RESUME_TURN_ID,
createdAt: "2026-08-15T00:10:00.000Z",
payload: {
taskId: "a34bb18edee269135",
toolUseId: "toolu_01WaB14B6ivDH4qPhA5QhpDx",
taskType: "local_agent",
subagentType: "claude",
description: "Fix missing-worktree bug trio",
},
}),
);
expect(resumed).toHaveLength(1);
expect(resumed[0]?.status).toBe("running");
expect(resumed[0]?.turnId).toBe(RESUME_TURN_ID);
expect(resumed[0]?.spawnCallId).toBe(SPAWN_TOOL_USE_ID);

// A background command starting under an unknown tool call is not an agent
// and gets no row of its own.
const unchanged = projectSubagentActivity(
resumed,
activity({
id: "a4",
kind: "task.started",
payload: { taskId: "bash-1", toolUseId: "toolu_bash", taskType: "local_bash" },
}),
);
expect(unchanged).toHaveLength(1);

const finished = projectSubagentActivity(
resumed,
activity({
id: "a5",
kind: "task.completed",
createdAt: "2026-08-15T00:20:00.000Z",
payload: {
taskId: "a34bb18edee269135",
status: "completed",
toolUseId: "toolu_01WaB14B6ivDH4qPhA5QhpDx",
},
}),
);
expect(finished).toHaveLength(1);
expect(finished[0]?.status).toBe("completed");
});

it("projects a failed Claude agent as failed, not running", () => {
const roster = projectSubagentActivity(
[],
Expand Down
56 changes: 55 additions & 1 deletion apps/server/src/orchestration/subagentProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,60 @@ function settleTaskCompletion(
return next;
}

const AGENT_TASK_TYPES = new Set(["local_agent", "remote_agent"]);

/** Background command tasks share the task activity kinds with agent tasks and
* must never move an agent's row. */
function isAgentTaskActivity(payload: UnknownRecord | null): boolean {
return (
text(payload?.subagentType) !== null || AGENT_TASK_TYPES.has(text(payload?.taskType) ?? "")
);
}

function isSettledStatus(status: OrchestrationSubagentStatus): boolean {
return status === "completed" || status === "failed" || status === "interrupted";
}

/** Re-opens a settled agent's row when its task reports work again.
*
* A background agent the model revives (Claude's `SendMessage`) runs again
* under its original task id but under the tool call that resumed it, and a
* provider session that restarted since the spawn remembers neither — the
* transcript link the task stream established is what still names the agent.
* Codex has no equivalent resume call, and its children report through collab
* items rather than this task stream, so nothing here applies to it.
*
* Update-only, and only for a row that already settled: a task matching no
* known agent must not invent a roster row, and a live agent must not have its
* spawn turn rewritten by its own progress. */
function reopenResumedAgentRun(
current: ReadonlyArray<OrchestrationSubagent>,
activity: OrchestrationThreadActivity,
): ReadonlyArray<OrchestrationSubagent> {
const payload = record(activity.payload);
if (!isAgentTaskActivity(payload)) return current;
const taskId = text(payload?.taskId);
const toolUseId = text(payload?.toolUseId);
const index = current.findIndex(
(entry) =>
(taskId !== null && entry.transcriptAgentId === taskId) ||
(toolUseId !== null &&
(entry.agentThreadId === toolUseId ||
entry.spawnCallId === toolUseId ||
entry.id === toolUseId)),
);
const existing = index >= 0 ? current[index] : undefined;
if (!existing || !isSettledStatus(existing.status)) return current;
const next = [...current];
next[index] = {
...existing,
status: "running",
turnId: activity.turnId ?? existing.turnId,
updatedAt: activity.createdAt,
};
return next;
}

/** Claude addresses an agent's on-disk transcript by the task id it reports on
* the task stream, not by the spawning tool_use id this roster is keyed by.
* The task stream is the only place the two are linked, so the link is folded
Expand Down Expand Up @@ -317,7 +371,7 @@ export function projectSubagentActivity(
activity: OrchestrationThreadActivity,
): ReadonlyArray<OrchestrationSubagent> {
if (activity.kind === "task.started" || activity.kind === "task.progress") {
return linkTaskTranscript(current, activity);
return reopenResumedAgentRun(linkTaskTranscript(current, activity), activity);
}
if (activity.kind === "task.completed") {
return linkTaskTranscript(settleTaskCompletion(current, activity), activity);
Expand Down
188 changes: 188 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4423,6 +4423,194 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("re-opens a background agent the model resumes with SendMessage", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const runtimeEventsFiber = yield* adapter.streamEvents.pipe(
Stream.takeUntil((event) => event.type === "turn.completed"),
Stream.runCollect,
Effect.forkChild,
);

const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});

yield* adapter.sendTurn({
threadId: session.threadId,
input: "delegate in the background",
attachments: [],
});

harness.query.emit({
type: "stream_event",
session_id: "sdk-session-resumed-agent",
uuid: "stream-resumed-agent-start",
parent_tool_use_id: null,
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "tool-resumed-agent",
name: "Agent",
input: {
description: "Build worktree cleanup",
prompt: "Build it and report back.",
subagent_type: "claude",
run_in_background: true,
},
},
},
} as unknown as SDKMessage);

harness.query.emit({
type: "user",
session_id: "sdk-session-resumed-agent",
uuid: "user-resumed-agent-launch",
parent_tool_use_id: null,
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-resumed-agent",
content: "Async agent launched successfully.",
},
],
},
tool_use_result: {
status: "async_launched",
isAsync: true,
agentId: "agent-resumed-identity",
description: "Build worktree cleanup",
prompt: "Build it and report back.",
outputFile: "/tmp/tasks/agent-resumed-identity.output",
},
} as unknown as SDKMessage);

harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "task-resumed-agent",
tool_use_id: "tool-resumed-agent",
description: "Build worktree cleanup",
subagent_type: "claude",
task_type: "local_agent",
session_id: "sdk-session-resumed-agent",
uuid: "resumed-agent-task-started",
} as unknown as SDKMessage);

harness.query.emit({
type: "system",
subtype: "task_notification",
task_id: "task-resumed-agent",
tool_use_id: "tool-resumed-agent",
status: "completed",
summary: "First pass done.",
session_id: "sdk-session-resumed-agent",
uuid: "resumed-agent-first-notification",
} as unknown as SDKMessage);

// The model sends the settled agent more work. Its later runs report
// under the resuming call, not the spawn.
harness.query.emit({
type: "assistant",
session_id: "sdk-session-resumed-agent",
uuid: "assistant-resumed-agent-send",
parent_tool_use_id: null,
message: {
id: "msg-resumed-agent-send",
role: "assistant",
model: "claude-opus-5",
content: [
{
type: "tool_use",
id: "tool-resume-call",
name: "SendMessage",
input: { to: "agent-resumed-identity", summary: "Rework the dialog" },
},
{
type: "tool_use",
id: "tool-resume-unknown",
name: "SendMessage",
input: { to: "someone-else", summary: "Not an agent of this thread" },
},
],
},
} as unknown as SDKMessage);

harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "task-resumed-agent",
tool_use_id: "tool-resume-call",
description: "Build worktree cleanup",
subagent_type: "claude",
task_type: "local_agent",
session_id: "sdk-session-resumed-agent",
uuid: "resumed-agent-second-task-started",
} as unknown as SDKMessage);

harness.query.emit({
type: "system",
subtype: "task_notification",
task_id: "task-resumed-agent",
tool_use_id: "tool-resume-call",
status: "completed",
summary: "Rework shipped.",
session_id: "sdk-session-resumed-agent",
uuid: "resumed-agent-second-notification",
} as unknown as SDKMessage);

harness.query.emit({
type: "result",
subtype: "success",
is_error: false,
errors: [],
session_id: "sdk-session-resumed-agent",
uuid: "result-resumed-agent",
} as unknown as SDKMessage);
harness.query.finish();

const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));

// Exactly one agent is revived, keyed by the spawn that owns its row.
const revivals = runtimeEvents.filter((event) => event.type === "subagent.metadata.updated");
assert.equal(revivals.length, 1);
const revival = revivals[0];
if (revival?.type === "subagent.metadata.updated") {
assert.equal(revival.payload.callId, "tool-resumed-agent");
assert.equal(revival.payload.agentThreadId, "tool-resumed-agent");
assert.equal(revival.payload.status, "running");
assert.equal(revival.payload.agentRole, "claude");
}

// The second run is a run of its own: it starts again and settles again.
const starts = runtimeEvents.filter(
(event) => event.type === "task.started" && event.payload.taskId === "task-resumed-agent",
);
assert.equal(starts.length, 2);
const completions = runtimeEvents.filter(
(event) => event.type === "task.completed" && event.payload.taskId === "task-resumed-agent",
);
assert.equal(completions.length, 2);
if (completions[1]?.type === "task.completed") {
assert.equal(completions[1].payload.summary, "Rework shipped.");
// The spawn still owns the agent's row, so the completion links there
// rather than to the call that resumed it.
assert.equal(completions[1].payload.toolUseId, "tool-resumed-agent");
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("forwards authoritative background task snapshots without inferring edges", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
Loading
Loading