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
12 changes: 8 additions & 4 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -337,7 +339,7 @@ function mapThreadActivityRow(
function mapThreadSubagentRow(
row: Schema.Schema.Type<typeof ProjectionThreadSubagentDbRowSchema>,
): OrchestrationSubagent {
const { threadId: _threadId, ...subagent } = row;
const { threadId: _threadId, ...subagent } = toProjectionThreadSubagent(row);
return subagent;
}

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/orchestration/subagentProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[],
Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/orchestration/subagentProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never, never> {
return value === undefined ? {} : { isBackgrounded: value };
}

function treeDepth(agentPath: string | null): number {
return Math.max(0, (agentPath?.split("/").filter(Boolean).length ?? 1) - 2);
}
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 25 additions & 2 deletions apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<typeof ProjectionThreadSubagentDbRowSchema>,
): 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",
Expand All @@ -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"),
),
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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`;
});
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/components/chat/agentsPanel.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/agentsPanel.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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 } : {}),
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/src/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/providerRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading