Skip to content

Commit b5fc0de

Browse files
committed
feat(claude): read task depth, background flag, and ambient from the SDK
Bump @anthropic-ai/claude-agent-sdk to 0.3.247 and use what it now reports about tasks. task.started carries isBackgrounded, spawnDepth, and ambient. A spawned agent's depth reaches its roster row through subagent metadata, so nested agents get a real tree depth. Ambient housekeeping (live-update watchers and the like) never counts as pending background work: it is flagged on its edges and skipped in snapshot counts, so it cannot hold a thread in "Background" or "Waiting". The session also declares perTaskStopAffordance, so Stop ends the current turn and leaves background agents running; they are stopped one at a time from the Agents tab.
1 parent dac7599 commit b5fc0de

7 files changed

Lines changed: 179 additions & 65 deletions

File tree

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,8 @@ describe("ProviderRuntimeIngestion", () => {
10421042
// provider accidentally repeats an id in its array.
10431043
{ taskId: "background-task-snapshot-1", taskType: "local_agent" },
10441044
{ taskId: "background-task-snapshot-2", taskType: "local_bash" },
1045+
// Ambient housekeeping is listed but is not work the user waits on.
1046+
{ taskId: "background-task-snapshot-ambient", taskType: "local_bash", ambient: true },
10451047
],
10461048
},
10471049
});

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2614,7 +2614,11 @@ const make = Effect.gen(function* () {
26142614
// absolute count so missed or reordered task edges cannot wedge the
26152615
// session in a stale background-work state.
26162616
if (event.type === "task.snapshot.updated" && thread.session != null) {
2617-
const nextPendingCount = new Set(event.payload.tasks.map((task) => task.taskId)).size;
2617+
// Ambient housekeeping (e.g. live-update watchers) is not work the
2618+
// user is waiting on, so it never holds the thread in "Background".
2619+
const nextPendingCount = new Set(
2620+
event.payload.tasks.filter((task) => task.ambient !== true).map((task) => task.taskId),
2621+
).size;
26182622
if (nextPendingCount !== (thread.session.pendingBackgroundTaskCount ?? 0)) {
26192623
yield* orchestrationEngine.dispatch({
26202624
type: "thread.session.set",

apps/server/src/provider/Layers/ClaudeAdapter.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3354,10 +3354,39 @@ describe("ClaudeAdapterLive", () => {
33543354
description: "Run dev server",
33553355
tool_use_id: "toolu-main-bash",
33563356
task_type: "local_bash",
3357+
is_backgrounded: true,
33573358
session_id: "sdk-session-owned-task",
33583359
uuid: "main-task-started",
33593360
} as unknown as SDKMessage);
33603361

3362+
// The agent's own task states its nesting depth; that is the only
3363+
// place the SDK does, and the spawn call keys the roster row.
3364+
harness.query.emit({
3365+
type: "system",
3366+
subtype: "task_started",
3367+
task_id: "task-agent",
3368+
description: "Fix the reactor",
3369+
tool_use_id: "tool-task-owner",
3370+
task_type: "local_agent",
3371+
subagent_type: "claude",
3372+
is_backgrounded: false,
3373+
spawn_depth: 1,
3374+
session_id: "sdk-session-owned-task",
3375+
uuid: "agent-task-started",
3376+
} as unknown as SDKMessage);
3377+
3378+
// Ambient housekeeping is reported but never counts as pending work.
3379+
harness.query.emit({
3380+
type: "system",
3381+
subtype: "task_started",
3382+
task_id: "task-watch",
3383+
description: "Live update watcher",
3384+
task_type: "local_bash",
3385+
ambient: true,
3386+
session_id: "sdk-session-owned-task",
3387+
uuid: "watch-task-started",
3388+
} as unknown as SDKMessage);
3389+
33613390
harness.query.emit({
33623391
type: "system",
33633392
subtype: "task_notification",
@@ -3394,6 +3423,34 @@ describe("ClaudeAdapterLive", () => {
33943423
assert.equal(mainStarted?.type, "task.started");
33953424
if (mainStarted?.type === "task.started") {
33963425
assert.isUndefined(mainStarted.payload.ownerAgentToolUseId);
3426+
assert.equal(mainStarted.payload.isBackgrounded, true);
3427+
}
3428+
const agentStarted = startedEvents.find(
3429+
(event) => event.type === "task.started" && String(event.payload.taskId) === "task-agent",
3430+
);
3431+
assert.equal(agentStarted?.type, "task.started");
3432+
if (agentStarted?.type === "task.started") {
3433+
assert.equal(agentStarted.payload.isBackgrounded, false);
3434+
assert.equal(agentStarted.payload.spawnDepth, 1);
3435+
assert.isUndefined(agentStarted.payload.ambient);
3436+
}
3437+
const depthMetadata = runtimeEvents.find(
3438+
(event) =>
3439+
event.type === "subagent.metadata.updated" &&
3440+
event.payload.callId === "tool-task-owner" &&
3441+
event.payload.treeDepth !== undefined,
3442+
);
3443+
assert.equal(depthMetadata?.type, "subagent.metadata.updated");
3444+
if (depthMetadata?.type === "subagent.metadata.updated") {
3445+
assert.equal(depthMetadata.payload.treeDepth, 1);
3446+
}
3447+
const watchStarted = startedEvents.find(
3448+
(event) => event.type === "task.started" && String(event.payload.taskId) === "task-watch",
3449+
);
3450+
assert.equal(watchStarted?.type, "task.started");
3451+
if (watchStarted?.type === "task.started") {
3452+
assert.equal(watchStarted.payload.ambient, true);
3453+
assert.equal(watchStarted.payload.pendingCountManagedBySnapshot, true);
33973454
}
33983455
const ownedCompleted = runtimeEvents.find(
33993456
(event) =>

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,9 @@ interface ClaudeTaskSnapshot {
328328
* by the main model. Learned at task_started and replayed on later task
329329
* events, which do not restate the originating tool. */
330330
readonly ownerAgentToolUseId?: string;
331+
/** Housekeeping the SDK does not count as user work (task_started.ambient).
332+
* Remembered so the completion edge stays out of the pending count too. */
333+
readonly ambient?: boolean;
331334
}
332335

333336
type ClaudeStructuredAgentToolResult =
@@ -4028,6 +4031,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
40284031
readonly subagentType?: string;
40294032
readonly taskType?: string;
40304033
readonly ownerAgentToolUseId?: string;
4034+
readonly isBackgrounded?: boolean;
4035+
readonly spawnDepth?: number;
4036+
readonly ambient?: boolean;
40314037
},
40324038
message: SDKMessage,
40334039
) {
@@ -4042,6 +4048,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
40424048
...(task.subagentType ? { subagentType: task.subagentType } : {}),
40434049
...(task.taskType ? { taskType: task.taskType } : {}),
40444050
...(task.ownerAgentToolUseId ? { ownerAgentToolUseId: task.ownerAgentToolUseId } : {}),
4051+
...(task.ambient === true ? { ambient: true } : {}),
40454052
status: "running",
40464053
});
40474054
if (context.startedTaskIds.has(task.taskId)) {
@@ -4064,7 +4071,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
40644071
...(task.toolUseId ? { toolUseId: task.toolUseId } : {}),
40654072
...(task.subagentType ? { subagentType: task.subagentType } : {}),
40664073
...(task.ownerAgentToolUseId ? { ownerAgentToolUseId: task.ownerAgentToolUseId } : {}),
4067-
...(context.backgroundTaskSnapshotObserved ? { pendingCountManagedBySnapshot: true } : {}),
4074+
...(task.isBackgrounded !== undefined ? { isBackgrounded: task.isBackgrounded } : {}),
4075+
...(task.spawnDepth !== undefined ? { spawnDepth: task.spawnDepth } : {}),
4076+
...(task.ambient === true ? { ambient: true } : {}),
4077+
// Ambient housekeeping never counts as pending background work.
4078+
...(context.backgroundTaskSnapshotObserved || task.ambient === true
4079+
? { pendingCountManagedBySnapshot: true }
4080+
: {}),
40684081
},
40694082
providerRefs: nativeProviderRefs(context),
40704083
raw: {
@@ -4120,7 +4133,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
41204133
...(task.usage !== undefined ? { usage: task.usage } : {}),
41214134
...(task.toolUseId ? { toolUseId: task.toolUseId } : {}),
41224135
...(ownerAgentToolUseId ? { ownerAgentToolUseId } : {}),
4123-
...(context.backgroundTaskSnapshotObserved ? { pendingCountManagedBySnapshot: true } : {}),
4136+
...(context.backgroundTaskSnapshotObserved || previous?.ambient === true
4137+
? { pendingCountManagedBySnapshot: true }
4138+
: {}),
41244139
},
41254140
providerRefs: nativeProviderRefs(context),
41264141
raw: {
@@ -5108,6 +5123,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
51085123
readonly taskId: RuntimeTaskId;
51095124
readonly taskType?: string;
51105125
readonly description?: string;
5126+
readonly ambient?: boolean;
51115127
}
51125128
>();
51135129
for (const task of message.tasks) {
@@ -5117,6 +5133,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
51175133
taskId: RuntimeTaskId.make(task.task_id),
51185134
...(description ? { description } : {}),
51195135
...(taskType ? { taskType } : {}),
5136+
...(task.ambient === true ? { ambient: true } : {}),
51205137
});
51215138
}
51225139
yield* offerRuntimeEvent({
@@ -5136,6 +5153,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
51365153
const ownerAgentToolUseId = toolUseId
51375154
? context.subagentToolUseOwners.get(toolUseId)
51385155
: undefined;
5156+
const isBackgrounded =
5157+
typeof message.is_backgrounded === "boolean" ? message.is_backgrounded : undefined;
5158+
const spawnDepth =
5159+
typeof message.spawn_depth === "number" && Number.isInteger(message.spawn_depth)
5160+
? Math.max(0, message.spawn_depth)
5161+
: undefined;
5162+
const ambient = message.ambient === true;
51395163
yield* emitTaskStartedOnce(
51405164
context,
51415165
{
@@ -5145,9 +5169,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
51455169
...(subagentType ? { subagentType } : {}),
51465170
...(taskType ? { taskType } : {}),
51475171
...(ownerAgentToolUseId ? { ownerAgentToolUseId } : {}),
5172+
...(isBackgrounded !== undefined ? { isBackgrounded } : {}),
5173+
...(spawnDepth !== undefined ? { spawnDepth } : {}),
5174+
...(ambient ? { ambient } : {}),
51485175
},
51495176
message,
51505177
);
5178+
// The SDK states an agent's nesting depth only here; the spawn call
5179+
// is what the roster row is keyed by until the agent id is known.
5180+
if (spawnDepth !== undefined && toolUseId) {
5181+
yield* emitSubagentMetadata(context, { callId: toolUseId, treeDepth: spawnDepth });
5182+
}
51515183
return;
51525184
}
51535185
case "task_progress": {
@@ -6474,6 +6506,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
64746506
// and streams them into the collab tool item instead. Travels via the
64756507
// control-protocol initConfig, so older CLIs just ignore it.
64766508
forwardSubagentText: true,
6509+
// Stop ends the current turn only; background agents and workflows
6510+
// keep running and are stopped one at a time from the Agents tab.
6511+
// Without this the CLI fails closed and an interrupt kills them all.
6512+
perTaskStopAffordance: true,
64776513
canUseTool,
64786514
hooks: {
64796515
PostToolUse: [

packages/contracts/src/providerRuntime.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,9 @@ export const SubagentMetadataUpdatedPayload = Schema.Struct({
350350
* from `agentThreadId`. */
351351
transcriptAgentId: Schema.optional(TrimmedNonEmptyStringSchema),
352352
agentPath: Schema.optional(TrimmedNonEmptyStringSchema),
353+
/** Nesting depth (1 = spawned by the main agent), for providers that report
354+
* it directly instead of through an agent path. */
355+
treeDepth: Schema.optional(NonNegativeInt),
353356
agentNickname: Schema.optional(TrimmedNonEmptyStringSchema),
354357
agentRole: Schema.optional(TrimmedNonEmptyStringSchema),
355358
taskName: Schema.optional(TrimmedNonEmptyStringSchema),
@@ -604,6 +607,15 @@ const TaskStartedPayload = Schema.Struct({
604607
* run the agent kicked off). Lets consumers attribute the task's rows to
605608
* the agent instead of narrating them as the conversation's own work. */
606609
ownerAgentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema),
610+
/** The task runs in the background (its spawning tool call returned at
611+
* once) rather than blocking the turn. */
612+
isBackgrounded: Schema.optional(Schema.Boolean),
613+
/** Nesting depth of a spawned agent task: 1 for a top-level spawn, N+1
614+
* when spawned from inside a depth-N agent. */
615+
spawnDepth: Schema.optional(NonNegativeInt),
616+
/** Housekeeping the provider does not count as user work (e.g. live-update
617+
* watchers). Never counts toward pending background work. */
618+
ambient: Schema.optional(Schema.Boolean),
607619
});
608620
export type TaskStartedPayload = typeof TaskStartedPayload.Type;
609621

@@ -615,6 +627,9 @@ const TaskSnapshotUpdatedPayload = Schema.Struct({
615627
taskId: RuntimeTaskId,
616628
taskType: Schema.optional(TrimmedNonEmptyStringSchema),
617629
description: Schema.optional(TrimmedNonEmptyStringSchema),
630+
/** See TaskStartedPayload.ambient. Ambient tasks stay in the snapshot
631+
* for task panels but do not count as pending background work. */
632+
ambient: Schema.optional(Schema.Boolean),
618633
}),
619634
),
620635
});

0 commit comments

Comments
 (0)