diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 4a51d2015..aeadf26b8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -53,7 +53,10 @@ import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionT import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; -import { ProjectionThreadSubagent } from "../../persistence/Services/ProjectionThreadSubagents.ts"; +import { + ProjectionThreadSubagentDbRowSchema, + toProjectionThreadSubagent, +} from "../../persistence/Layers/ProjectionThreadSubagents.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; @@ -105,7 +108,6 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( }), ); const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; -const ProjectionThreadSubagentDbRowSchema = ProjectionThreadSubagent; const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( Struct.assign({ files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), @@ -337,7 +339,7 @@ function mapThreadActivityRow( function mapThreadSubagentRow( row: Schema.Schema.Type, ): OrchestrationSubagent { - const { threadId: _threadId, ...subagent } = row; + const { threadId: _threadId, ...subagent } = toProjectionThreadSubagent(row); return subagent; } @@ -754,7 +756,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { parent_agent_thread_id AS "parentAgentThreadId", spawn_call_id AS "spawnCallId", transcript_agent_id AS "transcriptAgentId", turn_id AS "turnId", agent_path AS "agentPath", parent_agent_path AS "parentAgentPath", - tree_depth AS "treeDepth", nickname, role, objective, status, + tree_depth AS "treeDepth", is_backgrounded AS "isBackgrounded", + nickname, role, objective, status, requested_model AS "requestedModel", resolved_model AS "resolvedModel", reasoning_effort AS "reasoningEffort", model_provenance AS "modelProvenance", reasoning_effort_provenance AS "reasoningEffortProvenance", @@ -1298,6 +1301,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { agent_path AS "agentPath", parent_agent_path AS "parentAgentPath", tree_depth AS "treeDepth", + is_backgrounded AS "isBackgrounded", nickname, role, objective, diff --git a/apps/server/src/orchestration/subagentProjection.test.ts b/apps/server/src/orchestration/subagentProjection.test.ts index faf508f97..598ce2710 100644 --- a/apps/server/src/orchestration/subagentProjection.test.ts +++ b/apps/server/src/orchestration/subagentProjection.test.ts @@ -82,6 +82,42 @@ describe("projectSubagentActivity", () => { expect(agent?.status).toBe("running"); }); + it("records whether the provider runs the agent in the background, and a later move", () => { + const spawned = projectSubagentActivity( + [], + claudeSpawnActivity({ + id: "a1", + kind: "tool.started", + status: "inProgress", + turnId: TURN_ID, + }), + ); + // Unknown until the provider says: the flag stays absent, not false. + expect(spawned[0]?.isBackgrounded).toBeUndefined(); + + const foreground = projectSubagentActivity( + spawned, + activity({ + id: "m1", + kind: "subagent.metadata", + payload: { callId: SPAWN_TOOL_USE_ID, treeDepth: 1, isBackgrounded: false }, + }), + ); + expect(foreground[0]?.isBackgrounded).toBe(false); + expect(foreground[0]?.treeDepth).toBe(1); + + const moved = projectSubagentActivity( + foreground, + activity({ + id: "m2", + kind: "subagent.metadata", + payload: { callId: SPAWN_TOOL_USE_ID, isBackgrounded: true }, + }), + ); + expect(moved[0]?.isBackgrounded).toBe(true); + expect(moved[0]?.treeDepth).toBe(1); + }); + it("keeps the spawn turn when later live-text updates arrive without a turn", () => { const spawned = projectSubagentActivity( [], diff --git a/apps/server/src/orchestration/subagentProjection.ts b/apps/server/src/orchestration/subagentProjection.ts index bd5f324ba..76e96a6ff 100644 --- a/apps/server/src/orchestration/subagentProjection.ts +++ b/apps/server/src/orchestration/subagentProjection.ts @@ -73,6 +73,14 @@ function parentPath(agentPath: string | null): string | null { return segments.length > 2 ? `/${segments.slice(0, -1).join("/")}` : null; } +/** The optional flag as an object spread, so an unknown value stays absent + * instead of becoming `undefined` under exact optional property types. */ +function backgroundedField( + value: boolean | undefined, +): { readonly isBackgrounded: boolean } | Record { + return value === undefined ? {} : { isBackgrounded: value }; +} + function treeDepth(agentPath: string | null): number { return Math.max(0, (agentPath?.split("/").filter(Boolean).length ?? 1) - 2); } @@ -87,6 +95,7 @@ interface SubagentPatch { readonly agentPath?: string | null; readonly parentAgentPath?: string | null; readonly treeDepth?: number; + readonly isBackgrounded?: boolean; readonly nickname?: string | null; readonly role?: string | null; readonly objective?: string | null; @@ -115,6 +124,7 @@ function metadataPatch(activity: OrchestrationThreadActivity): SubagentPatch | n ); const model = text(payload.model); const status = explicitStatus(payload.status); + const depth = integer(payload.treeDepth) ?? (agentPath === null ? null : treeDepth(agentPath)); return { id, agentThreadId, @@ -124,7 +134,12 @@ function metadataPatch(activity: OrchestrationThreadActivity): SubagentPatch | n turnId: (text(payload.turnId) as TurnId | null) ?? activity.turnId, agentPath, parentAgentPath: text(payload.parentAgentPath) ?? parentPath(agentPath), - treeDepth: integer(payload.treeDepth) ?? treeDepth(agentPath), + // Only a payload that knows the depth may set it: a metadata update about + // something else (a move to the background) must not reset it to 0. + ...(depth === null ? {} : { treeDepth: depth }), + ...backgroundedField( + typeof payload.isBackgrounded === "boolean" ? payload.isBackgrounded : undefined, + ), nickname: text(payload.nickname) ?? text(payload.agentNickname), role: text(payload.role) ?? text(payload.agentRole) ?? text(payload.taskName), objective: text(payload.objective) ?? text(payload.prompt), @@ -241,6 +256,7 @@ function mergeSubagent( agentPath: mergeValue(patch.agentPath, current?.agentPath ?? null), parentAgentPath: mergeValue(patch.parentAgentPath, current?.parentAgentPath ?? null), treeDepth: patch.treeDepth ?? current?.treeDepth ?? 0, + ...backgroundedField(patch.isBackgrounded ?? current?.isBackgrounded), nickname: mergeValue(patch.nickname, current?.nickname ?? null), role: mergeValue(patch.role, current?.role ?? null), objective: mergeValue(patch.objective, current?.objective ?? null), @@ -443,6 +459,7 @@ function duplicatePatchFrom(duplicate: OrchestrationSubagent): SubagentPatch { agentPath: duplicate.agentPath, parentAgentPath: duplicate.parentAgentPath, treeDepth: duplicate.treeDepth, + ...backgroundedField(duplicate.isBackgrounded), nickname: duplicate.nickname, role: duplicate.role, objective: duplicate.objective, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts b/apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts index 548b72e08..74445dabb 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts @@ -1,6 +1,8 @@ import type { OrchestrationSubagent } from "@threadlines/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; @@ -12,19 +14,38 @@ import { type ProjectionThreadSubagentRepositoryShape, } from "../Services/ProjectionThreadSubagents.ts"; +/** SQLite keeps the optional flag as NULL/0/1. */ +export const ProjectionThreadSubagentDbRowSchema = ProjectionThreadSubagent.mapFields( + Struct.assign({ + isBackgrounded: Schema.NullOr(Schema.Number), + }), +); + +export function toProjectionThreadSubagent( + row: Schema.Schema.Type, +): ProjectionThreadSubagent { + const { isBackgrounded, ...rest } = row; + return isBackgrounded === null ? rest : { ...rest, isBackgrounded: isBackgrounded === 1 }; +} + +export function subagentBackgroundedColumn(row: OrchestrationSubagent): number | null { + return row.isBackgrounded === undefined ? null : row.isBackgrounded ? 1 : 0; +} + const makeProjectionThreadSubagentRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const listRows = SqlSchema.findAll({ Request: ProjectionThreadSubagentThreadInput, - Result: ProjectionThreadSubagent, + Result: ProjectionThreadSubagentDbRowSchema, execute: ({ threadId }) => sql` SELECT thread_id AS "threadId", subagent_id AS "id", agent_thread_id AS "agentThreadId", parent_agent_thread_id AS "parentAgentThreadId", spawn_call_id AS "spawnCallId", transcript_agent_id AS "transcriptAgentId", turn_id AS "turnId", agent_path AS "agentPath", parent_agent_path AS "parentAgentPath", - tree_depth AS "treeDepth", nickname, role, objective, status, + tree_depth AS "treeDepth", is_backgrounded AS "isBackgrounded", + nickname, role, objective, status, requested_model AS "requestedModel", resolved_model AS "resolvedModel", reasoning_effort AS "reasoningEffort", model_provenance AS "modelProvenance", reasoning_effort_provenance AS "reasoningEffortProvenance", @@ -38,6 +59,7 @@ const makeProjectionThreadSubagentRepository = Effect.gen(function* () { const listByThreadId: ProjectionThreadSubagentRepositoryShape["listByThreadId"] = (input) => listRows(input).pipe( + Effect.map((rows) => rows.map(toProjectionThreadSubagent)), Effect.mapError( toPersistenceSqlError("ProjectionThreadSubagentRepository.listByThreadId:query"), ), @@ -63,6 +85,7 @@ const makeProjectionThreadSubagentRepository = Effect.gen(function* () { agent_path: row.agentPath, parent_agent_path: row.parentAgentPath, tree_depth: row.treeDepth, + is_backgrounded: subagentBackgroundedColumn(row), nickname: row.nickname, role: row.role, objective: row.objective, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 6fbc786c2..9a75ae61d 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -61,6 +61,7 @@ import Migration0045 from "./Migrations/045_SettleStoppedProjectionTurns.ts"; import Migration0046 from "./Migrations/046_ProjectionTurnsCheckpointCompletedAt.ts"; import Migration0047 from "./Migrations/047_ProjectionThreadSubagents.ts"; import Migration0048 from "./Migrations/048_BackfillThreadSubagents.ts"; +import Migration0049 from "./Migrations/049_ProjectionThreadSubagentsBackgrounded.ts"; /** * Migration loader with all migrations defined inline. @@ -121,6 +122,7 @@ export const migrationEntries = [ [46, "ProjectionTurnsCheckpointCompletedAt", Migration0046], [47, "ProjectionThreadSubagents", Migration0047], [48, "BackfillThreadSubagents", Migration0048], + [49, "ProjectionThreadSubagentsBackgrounded", Migration0049], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadSubagentsBackgrounded.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadSubagentsBackgrounded.ts new file mode 100644 index 000000000..e84d7f035 --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadSubagentsBackgrounded.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** Whether the provider runs a spawned agent in the background (its spawn + * returned without blocking the turn). NULL when the provider never said; + * 0/1 otherwise. */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql`ALTER TABLE projection_thread_subagents ADD COLUMN is_backgrounded INTEGER`; +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 603dec18b..832d5684c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3443,6 +3443,7 @@ describe("ClaudeAdapterLive", () => { assert.equal(depthMetadata?.type, "subagent.metadata.updated"); if (depthMetadata?.type === "subagent.metadata.updated") { assert.equal(depthMetadata.payload.treeDepth, 1); + assert.equal(depthMetadata.payload.isBackgrounded, false); } const watchStarted = startedEvents.find( (event) => event.type === "task.started" && String(event.payload.taskId) === "task-watch", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d6b8ee224..be5a4b73e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -5178,7 +5178,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // The SDK states an agent's nesting depth only here; the spawn call // is what the roster row is keyed by until the agent id is known. if (spawnDepth !== undefined && toolUseId) { - yield* emitSubagentMetadata(context, { callId: toolUseId, treeDepth: spawnDepth }); + yield* emitSubagentMetadata(context, { + callId: toolUseId, + treeDepth: spawnDepth, + ...(isBackgrounded !== undefined ? { isBackgrounded } : {}), + }); } return; } @@ -5255,6 +5259,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(description ? { description } : {}), ...(status ? { status } : {}), }); + // A foreground agent moved to the background (Ctrl+B, or the model + // backgrounding it): the roster row should say so from now on. + if (patch.is_backgrounded === true && previous?.toolUseId) { + yield* emitSubagentMetadata(context, { + callId: previous.toolUseId, + isBackgrounded: true, + }); + } yield* offerRuntimeEvent({ ...base, diff --git a/apps/web/src/components/chat/agentsPanel.logic.test.ts b/apps/web/src/components/chat/agentsPanel.logic.test.ts index c75d1b575..ac2ebdda7 100644 --- a/apps/web/src/components/chat/agentsPanel.logic.test.ts +++ b/apps/web/src/components/chat/agentsPanel.logic.test.ts @@ -361,6 +361,33 @@ describe("buildAgentsPanelView", () => { expect(view.current[0]?.meta).toEqual(["gpt-5.6-sol", "high", "36s"]); }); + it("marks a live agent the provider runs in the background, after its identity", () => { + const build = (isBackgrounded: boolean, status: SubagentProgressItem["status"]) => + buildSubagent({ + id: `bg-${String(isBackgrounded)}-${status}`, + agentThreadId: `bg-${String(isBackgrounded)}-${status}`, + status, + statusLabel: status === "running" ? "Running" : "Done", + model: "claude-opus-5", + reasoningEffort: null, + telemetry: null, + isBackgrounded, + createdAt: "2026-08-11T10:11:24.000Z", + updatedAt: "2026-08-11T10:11:54.000Z", + }); + const view = buildAgentsPanelView({ + subagents: [build(true, "running"), build(false, "running"), build(true, "completed")], + nowMs: Date.parse("2026-08-11T10:12:00.000Z"), + }); + + // A finished row is a receipt; how it was launched no longer matters there. + expect(view.current.map((branch) => branch.meta)).toEqual([ + ["claude-opus-5", "background", "36s"], + ["claude-opus-5", "36s"], + ["claude-opus-5"], + ]); + }); + it("closes the meta line with what the agent wrote, live and in history", () => { const telemetry = { step: null, diff --git a/apps/web/src/components/chat/agentsPanel.logic.ts b/apps/web/src/components/chat/agentsPanel.logic.ts index 946d3a53e..527adfe68 100644 --- a/apps/web/src/components/chat/agentsPanel.logic.ts +++ b/apps/web/src/components/chat/agentsPanel.logic.ts @@ -159,6 +159,9 @@ function subagentBranch( // with the model also keeps the line still while the clock ticks. meta: [ ...agentIdentityMetaParts(item), + // Launched without the turn waiting on it: the parent is not blocked + // here, and the row can outlive the turn. + ...(live && item.isBackgrounded === true ? ["background"] : []), ...formatSubagentMetaParts(item, { context: details.context, elapsed: live ? formatElapsedDurationLabel(item.createdAt, nowMs) : null, diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 8a67f45a7..c28519b78 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -265,6 +265,8 @@ export interface SubagentProgressItem { parentAgentPath?: string | null; /** Visual nesting below the first child of `/root`. */ treeDepth?: number; + /** See OrchestrationSubagent.isBackgrounded. */ + isBackgrounded?: boolean; turnId: TurnId | null; label: string; nickname?: string | null; @@ -1212,6 +1214,7 @@ function collectSubagentActivityRecords( agentPath: subagent.agentPath, parentAgentPath: subagent.parentAgentPath, treeDepth: subagent.treeDepth, + ...(subagent.isBackgrounded !== undefined ? { isBackgrounded: subagent.isBackgrounded } : {}), turnId: subagent.turnId, label, ...(subagent.nickname ? { nickname: subagent.nickname } : {}), @@ -1743,6 +1746,7 @@ function applySubagentMetadataActivity( agentPath: asTrimmedString(payload.agentPath) ?? previous?.agentPath ?? null, parentAgentPath: asTrimmedString(payload.parentAgentPath) ?? previous?.parentAgentPath ?? null, treeDepth: previous?.treeDepth ?? 0, + ...(previous?.isBackgrounded !== undefined ? { isBackgrounded: previous.isBackgrounded } : {}), turnId: activity.turnId ?? previous?.turnId ?? null, label: subagentDisplayLabel({ role, nickname: null }), ...(nickname ? { nickname } : {}), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a5313bc8c..98e22d6c7 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -688,6 +688,10 @@ export const OrchestrationSubagent = Schema.Struct({ agentPath: Schema.NullOr(TrimmedNonEmptyString), parentAgentPath: Schema.NullOr(TrimmedNonEmptyString), treeDepth: NonNegativeInt, + /** The provider runs this agent in the background: its spawn returned at + * once instead of blocking the turn. Absent when the provider does not say + * (Codex agents always block their parent). */ + isBackgrounded: Schema.optional(Schema.Boolean), nickname: Schema.NullOr(TrimmedNonEmptyString), role: Schema.NullOr(TrimmedNonEmptyString), objective: Schema.NullOr(TrimmedNonEmptyString), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 039ad9b71..628dbe05a 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -353,6 +353,8 @@ export const SubagentMetadataUpdatedPayload = Schema.Struct({ /** Nesting depth (1 = spawned by the main agent), for providers that report * it directly instead of through an agent path. */ treeDepth: Schema.optional(NonNegativeInt), + /** See OrchestrationSubagent.isBackgrounded. */ + isBackgrounded: Schema.optional(Schema.Boolean), agentNickname: Schema.optional(TrimmedNonEmptyStringSchema), agentRole: Schema.optional(TrimmedNonEmptyStringSchema), taskName: Schema.optional(TrimmedNonEmptyStringSchema),