Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,8 @@ describe("ProviderRuntimeIngestion", () => {
// provider accidentally repeats an id in its array.
{ taskId: "background-task-snapshot-1", taskType: "local_agent" },
{ taskId: "background-task-snapshot-2", taskType: "local_bash" },
// Ambient housekeeping is listed but is not work the user waits on.
{ taskId: "background-task-snapshot-ambient", taskType: "local_bash", ambient: true },
],
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2614,7 +2614,11 @@ const make = Effect.gen(function* () {
// absolute count so missed or reordered task edges cannot wedge the
// session in a stale background-work state.
if (event.type === "task.snapshot.updated" && thread.session != null) {
const nextPendingCount = new Set(event.payload.tasks.map((task) => task.taskId)).size;
// Ambient housekeeping (e.g. live-update watchers) is not work the
// user is waiting on, so it never holds the thread in "Background".
const nextPendingCount = new Set(
event.payload.tasks.filter((task) => task.ambient !== true).map((task) => task.taskId),
).size;
if (nextPendingCount !== (thread.session.pendingBackgroundTaskCount ?? 0)) {
yield* orchestrationEngine.dispatch({
type: "thread.session.set",
Expand Down
57 changes: 57 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3354,10 +3354,39 @@ describe("ClaudeAdapterLive", () => {
description: "Run dev server",
tool_use_id: "toolu-main-bash",
task_type: "local_bash",
is_backgrounded: true,
session_id: "sdk-session-owned-task",
uuid: "main-task-started",
} as unknown as SDKMessage);

// The agent's own task states its nesting depth; that is the only
// place the SDK does, and the spawn call keys the roster row.
harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "task-agent",
description: "Fix the reactor",
tool_use_id: "tool-task-owner",
task_type: "local_agent",
subagent_type: "claude",
is_backgrounded: false,
spawn_depth: 1,
session_id: "sdk-session-owned-task",
uuid: "agent-task-started",
} as unknown as SDKMessage);

// Ambient housekeeping is reported but never counts as pending work.
harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "task-watch",
description: "Live update watcher",
task_type: "local_bash",
ambient: true,
session_id: "sdk-session-owned-task",
uuid: "watch-task-started",
} as unknown as SDKMessage);

harness.query.emit({
type: "system",
subtype: "task_notification",
Expand Down Expand Up @@ -3394,6 +3423,34 @@ describe("ClaudeAdapterLive", () => {
assert.equal(mainStarted?.type, "task.started");
if (mainStarted?.type === "task.started") {
assert.isUndefined(mainStarted.payload.ownerAgentToolUseId);
assert.equal(mainStarted.payload.isBackgrounded, true);
}
const agentStarted = startedEvents.find(
(event) => event.type === "task.started" && String(event.payload.taskId) === "task-agent",
);
assert.equal(agentStarted?.type, "task.started");
if (agentStarted?.type === "task.started") {
assert.equal(agentStarted.payload.isBackgrounded, false);
assert.equal(agentStarted.payload.spawnDepth, 1);
assert.isUndefined(agentStarted.payload.ambient);
}
const depthMetadata = runtimeEvents.find(
(event) =>
event.type === "subagent.metadata.updated" &&
event.payload.callId === "tool-task-owner" &&
event.payload.treeDepth !== undefined,
);
assert.equal(depthMetadata?.type, "subagent.metadata.updated");
if (depthMetadata?.type === "subagent.metadata.updated") {
assert.equal(depthMetadata.payload.treeDepth, 1);
}
const watchStarted = startedEvents.find(
(event) => event.type === "task.started" && String(event.payload.taskId) === "task-watch",
);
assert.equal(watchStarted?.type, "task.started");
if (watchStarted?.type === "task.started") {
assert.equal(watchStarted.payload.ambient, true);
assert.equal(watchStarted.payload.pendingCountManagedBySnapshot, true);
}
const ownedCompleted = runtimeEvents.find(
(event) =>
Expand Down
40 changes: 38 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,9 @@ interface ClaudeTaskSnapshot {
* by the main model. Learned at task_started and replayed on later task
* events, which do not restate the originating tool. */
readonly ownerAgentToolUseId?: string;
/** Housekeeping the SDK does not count as user work (task_started.ambient).
* Remembered so the completion edge stays out of the pending count too. */
readonly ambient?: boolean;
}

type ClaudeStructuredAgentToolResult =
Expand Down Expand Up @@ -4028,6 +4031,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
readonly subagentType?: string;
readonly taskType?: string;
readonly ownerAgentToolUseId?: string;
readonly isBackgrounded?: boolean;
readonly spawnDepth?: number;
readonly ambient?: boolean;
},
message: SDKMessage,
) {
Expand All @@ -4042,6 +4048,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(task.subagentType ? { subagentType: task.subagentType } : {}),
...(task.taskType ? { taskType: task.taskType } : {}),
...(task.ownerAgentToolUseId ? { ownerAgentToolUseId: task.ownerAgentToolUseId } : {}),
...(task.ambient === true ? { ambient: true } : {}),
status: "running",
});
if (context.startedTaskIds.has(task.taskId)) {
Expand All @@ -4064,7 +4071,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(task.toolUseId ? { toolUseId: task.toolUseId } : {}),
...(task.subagentType ? { subagentType: task.subagentType } : {}),
...(task.ownerAgentToolUseId ? { ownerAgentToolUseId: task.ownerAgentToolUseId } : {}),
...(context.backgroundTaskSnapshotObserved ? { pendingCountManagedBySnapshot: true } : {}),
...(task.isBackgrounded !== undefined ? { isBackgrounded: task.isBackgrounded } : {}),
...(task.spawnDepth !== undefined ? { spawnDepth: task.spawnDepth } : {}),
...(task.ambient === true ? { ambient: true } : {}),
// Ambient housekeeping never counts as pending background work.
...(context.backgroundTaskSnapshotObserved || task.ambient === true
? { pendingCountManagedBySnapshot: true }
: {}),
},
providerRefs: nativeProviderRefs(context),
raw: {
Expand Down Expand Up @@ -4120,7 +4133,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(task.usage !== undefined ? { usage: task.usage } : {}),
...(task.toolUseId ? { toolUseId: task.toolUseId } : {}),
...(ownerAgentToolUseId ? { ownerAgentToolUseId } : {}),
...(context.backgroundTaskSnapshotObserved ? { pendingCountManagedBySnapshot: true } : {}),
...(context.backgroundTaskSnapshotObserved || previous?.ambient === true
? { pendingCountManagedBySnapshot: true }
: {}),
},
providerRefs: nativeProviderRefs(context),
raw: {
Expand Down Expand Up @@ -5108,6 +5123,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
readonly taskId: RuntimeTaskId;
readonly taskType?: string;
readonly description?: string;
readonly ambient?: boolean;
}
>();
for (const task of message.tasks) {
Expand All @@ -5117,6 +5133,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
taskId: RuntimeTaskId.make(task.task_id),
...(description ? { description } : {}),
...(taskType ? { taskType } : {}),
...(task.ambient === true ? { ambient: true } : {}),
});
}
yield* offerRuntimeEvent({
Expand All @@ -5136,6 +5153,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
const ownerAgentToolUseId = toolUseId
? context.subagentToolUseOwners.get(toolUseId)
: undefined;
const isBackgrounded =
typeof message.is_backgrounded === "boolean" ? message.is_backgrounded : undefined;
const spawnDepth =
typeof message.spawn_depth === "number" && Number.isInteger(message.spawn_depth)
? Math.max(0, message.spawn_depth)
: undefined;
const ambient = message.ambient === true;
yield* emitTaskStartedOnce(
context,
{
Expand All @@ -5145,9 +5169,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(subagentType ? { subagentType } : {}),
...(taskType ? { taskType } : {}),
...(ownerAgentToolUseId ? { ownerAgentToolUseId } : {}),
...(isBackgrounded !== undefined ? { isBackgrounded } : {}),
...(spawnDepth !== undefined ? { spawnDepth } : {}),
...(ambient ? { ambient } : {}),
},
message,
);
// 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 });
}
return;
}
case "task_progress": {
Expand Down Expand Up @@ -6474,6 +6506,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
// and streams them into the collab tool item instead. Travels via the
// control-protocol initConfig, so older CLIs just ignore it.
forwardSubagentText: true,
// Stop ends the current turn only; background agents and workflows
// keep running and are stopped one at a time from the Agents tab.
// Without this the CLI fails closed and an interrupt kills them all.
perTaskStopAffordance: true,
canUseTool,
hooks: {
PostToolUse: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ describe("CodexAdapter item mapping", () => {
agentNickname: "Mercury",
agentRole: "explorer",
cliVersion: "1.0.0",
projectId: null,
createdAt: 1_786_650_001,
cwd: "C:/repo",
ephemeral: false,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ it.effect("discovers compatible root Codex conversations without exposing subage
preview: `Preview for ${id}`,
cwd: "/tmp/project",
cliVersion: "0.145.0",
projectId: null,
modelProvider: "openai",
createdAt: 1_760_000_000,
updatedAt: 1_760_000_100,
Expand Down Expand Up @@ -2334,6 +2335,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
itemId: "item-user-input-1",
threadId: "thread-1",
turnId: "turn-1",
isBlocking: true,
questions: [
{
id: "sandbox_mode",
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,17 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun
case "team":
return "ChatGPT Team Subscription";
case "self_serve_business_usage_based":
case "self_serve_business_prolite":
case "business":
return "ChatGPT Business Subscription";
case "ent26":
case "enterprise_cbp_usage_based":
case "enterprise_cbp_automation":
case "enterprise":
return "ChatGPT Enterprise Subscription";
case "edu":
case "edu_plus":
case "edu_pro":
return "ChatGPT Edu Subscription";
case "unknown":
return "ChatGPT Subscription";
Expand Down Expand Up @@ -761,7 +765,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
client
.request("account/rateLimits/read", undefined)
.pipe(Effect.orElseSucceed(() => undefined)),
client.request("account/usage/read", undefined).pipe(Effect.orElseSucceed(() => undefined)),
client.request("account/usage/read", {}).pipe(Effect.orElseSucceed(() => undefined)),
],
{ concurrency: "unbounded" },
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,7 @@ describe("openCodexThread", () => {
preview: "",
cwd: "/tmp/project",
cliVersion: "0.145.0",
projectId: null,
modelProvider: "openai",
createdAt: 1_760_000_000,
updatedAt: 1_760_000_001,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/provider/providerExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,8 @@
authStatus: CodexSchema.V2ListMcpServerStatusResponse__McpAuthStatus,
): string {
switch (authStatus) {
case "unknown":
return "Auth status unknown";
case "unsupported":
return "No auth required";
case "notLoggedIn":
Expand Down Expand Up @@ -3205,7 +3207,7 @@
const CLAUDE_PTY_COLUMNS = 120;
const CLAUDE_PTY_ROWS = 30;
const ANSI_ESCAPE_PATTERN =
/\u001B\[[0-9;?]*[A-Za-z]|\u001B\]8;[^\u0007\u001B]*(?:\u0007|\u001B\\)/g;

Check warning on line 3210 in apps/server/src/provider/providerExtensions.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Build

eslint(no-control-regex)

Unexpected control characters

function stripTerminalEscapes(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, "");
Expand Down
15 changes: 15 additions & 0 deletions packages/contracts/src/providerRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,9 @@ export const SubagentMetadataUpdatedPayload = Schema.Struct({
* from `agentThreadId`. */
transcriptAgentId: Schema.optional(TrimmedNonEmptyStringSchema),
agentPath: Schema.optional(TrimmedNonEmptyStringSchema),
/** Nesting depth (1 = spawned by the main agent), for providers that report
* it directly instead of through an agent path. */
treeDepth: Schema.optional(NonNegativeInt),
agentNickname: Schema.optional(TrimmedNonEmptyStringSchema),
agentRole: Schema.optional(TrimmedNonEmptyStringSchema),
taskName: Schema.optional(TrimmedNonEmptyStringSchema),
Expand Down Expand Up @@ -604,6 +607,15 @@ const TaskStartedPayload = Schema.Struct({
* run the agent kicked off). Lets consumers attribute the task's rows to
* the agent instead of narrating them as the conversation's own work. */
ownerAgentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema),
/** The task runs in the background (its spawning tool call returned at
* once) rather than blocking the turn. */
isBackgrounded: Schema.optional(Schema.Boolean),
/** Nesting depth of a spawned agent task: 1 for a top-level spawn, N+1
* when spawned from inside a depth-N agent. */
spawnDepth: Schema.optional(NonNegativeInt),
/** Housekeeping the provider does not count as user work (e.g. live-update
* watchers). Never counts toward pending background work. */
ambient: Schema.optional(Schema.Boolean),
});
export type TaskStartedPayload = typeof TaskStartedPayload.Type;

Expand All @@ -615,6 +627,9 @@ const TaskSnapshotUpdatedPayload = Schema.Struct({
taskId: RuntimeTaskId,
taskType: Schema.optional(TrimmedNonEmptyStringSchema),
description: Schema.optional(TrimmedNonEmptyStringSchema),
/** See TaskStartedPayload.ambient. Ambient tasks stay in the snapshot
* for task panels but do not count as pending background work. */
ambient: Schema.optional(Schema.Boolean),
}),
),
});
Expand Down
Loading
Loading