diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 22b3dbf62ab0..699a062e261c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -9,6 +9,8 @@ import { ProviderRuntimeEvent, ProviderSession, ProviderInstanceId, + RuntimeItemId, + RuntimeTaskId, } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -70,6 +72,49 @@ const asThreadId = (value: string): ThreadId => ThreadId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); describe("runtimeEventToActivities", () => { + it("preserves explicit external-agent and launcher classification", () => { + const createdAt = "2026-08-12T00:00:00.000Z"; + const [taskActivity] = runtimeEventToActivities({ + type: "task.started", + eventId: asEventId("evt-external-agent"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload: { + taskId: RuntimeTaskId.make("codex-1"), + description: "Review before merge", + taskType: "local_bash", + agentKind: "agent", + role: "codex", + }, + }); + expect(taskActivity?.payload).toMatchObject({ + taskId: "codex-1", + taskType: "local_bash", + agentKind: "agent", + role: "codex", + }); + + const [launcherActivity] = runtimeEventToActivities({ + type: "item.started", + eventId: asEventId("evt-external-agent-launcher"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: RuntimeItemId.make("tool-codex-1"), + payload: { + itemType: "command_execution", + timelineBypass: true, + }, + }); + expect(launcherActivity?.payload).toMatchObject({ + itemType: "command_execution", + timelineBypass: true, + }); + }); + it("persists prompt suggestions as hidden turn-scoped composer metadata", () => { const activities = runtimeEventToActivities({ type: "thread.metadata.updated", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7301e53f8de8..4cee9fd06a6b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -317,15 +317,21 @@ function requestKindFromCanonicalRequestType( * client folds survive activity retention; absent fields stay absent. */ function taskLinkageActivityFields(payload: Record): Record { + const explicitAgentKind = + payload.agentKind === "agent" || payload.agentKind === "background" + ? payload.agentKind + : undefined; const fields: Record = { // Server-stamped classification: persisted rows are self-describing, so // clients trust the stamp instead of re-deriving agent-vs-background // from taskType denylists and marker heuristics (legacy rows without a // stamp keep the client fallback). - agentKind: classifyTaskAgentKind({ - taskType: typeof payload.taskType === "string" ? payload.taskType : undefined, - agentId: typeof payload.agentId === "string" ? payload.agentId : undefined, - }), + agentKind: + explicitAgentKind ?? + classifyTaskAgentKind({ + taskType: typeof payload.taskType === "string" ? payload.taskType : undefined, + agentId: typeof payload.agentId === "string" ? payload.agentId : undefined, + }), }; for (const key of [ "taskType", @@ -823,6 +829,7 @@ export function runtimeEventToActivities( ...(event.payload.parentToolUseId ? { parentToolUseId: event.payload.parentToolUseId } : {}), + ...(event.payload.timelineBypass ? { timelineBypass: true } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -849,6 +856,7 @@ export function runtimeEventToActivities( ...(event.payload.parentToolUseId ? { parentToolUseId: event.payload.parentToolUseId } : {}), + ...(event.payload.timelineBypass ? { timelineBypass: true } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -874,6 +882,7 @@ export function runtimeEventToActivities( ...(event.payload.parentToolUseId ? { parentToolUseId: event.payload.parentToolUseId } : {}), + ...(event.payload.timelineBypass ? { timelineBypass: true } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -1992,6 +2001,7 @@ const make = Effect.gen(function* () { taskType?: string; status?: string; agentId?: string; + agentKind?: "agent" | "background"; }; threadBackgroundLiveness.recordTaskLiveness({ threadId: thread.id, @@ -1999,6 +2009,7 @@ const make = Effect.gen(function* () { taskType: payload.taskType, status: payload.status, agentId: payload.agentId, + agentKind: payload.agentKind, kind: event.type === "task.started" ? "started" diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts index 0c4841e8119a..904697aa1974 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts @@ -60,6 +60,29 @@ describe("ThreadBackgroundLiveness", () => { expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); }); + it("lets adapters promote a background shell to agent liveness", () => { + const liveness = ThreadBackgroundLiveness.make(); + const threadId = "t-live-external-agent"; + liveness.recordTaskLiveness({ + threadId, + taskId: "codex-1", + taskType: "local_bash", + agentKind: "agent", + status: undefined, + kind: "started", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working"); + liveness.recordTaskLiveness({ + threadId, + taskId: "codex-1", + taskType: "local_bash", + agentKind: "agent", + status: "completed", + kind: "completed", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); + }); + it("nested agents (agentId + agent taskType) still count toward liveness", () => { const liveness = ThreadBackgroundLiveness.make(); const threadId = "t-live-nested"; diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts index 8563e7665fb0..20af970cc489 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts @@ -15,7 +15,7 @@ * * @module ThreadBackgroundLivenessService */ -import { INERT_TASK_TYPES, MONITOR_TASK_TYPES } from "@t3tools/contracts"; +import { classifyTaskAgentKind, INERT_TASK_TYPES } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -27,11 +27,10 @@ interface ThreadLivenessState { readonly monitors: Set; } -// Classification sets are the shared contracts copies (MONITOR_TASK_TYPES: -// watch loops — monitor tasks plus background shells, which in practice are -// PR babysitting/log tails since pacing sleeps complete inside the turn; -// INERT_TASK_TYPES: plan-mode bookkeeping) so this registry, ingestion's -// agentKind stamp, and the client fold can never drift apart. +// Classification comes from the shared contracts helper so this registry, +// ingestion's agentKind stamp, and the client fold can never drift apart. +// INERT_TASK_TYPES remains useful here because those tasks should disappear +// from liveness entirely unless an adapter explicitly promotes one to agent. const TERMINAL_STATUSES: ReadonlySet = new Set([ "completed", @@ -59,6 +58,7 @@ export class ThreadBackgroundLivenessService extends Context.Service< readonly status: string | undefined; readonly kind: "started" | "progress" | "updated" | "completed"; readonly agentId?: string | undefined; + readonly agentKind?: "agent" | "background" | undefined; }) => void; /** Session death orphans all of a thread's background work. */ @@ -104,17 +104,20 @@ export function make(): ThreadBackgroundLivenessService["Service"] { return { recordTaskLiveness: (input) => { const taskType = input.taskType; - if (taskType !== undefined && INERT_TASK_TYPES.has(taskType)) { + const agentKind = + input.agentKind ?? + classifyTaskAgentKind({ + taskType, + agentId: input.agentId, + }); + if (agentKind !== "agent" && taskType !== undefined && INERT_TASK_TYPES.has(taskType)) { drop(input.threadId, input.taskId); return; } // A subagent's internal non-agent work (its own shells/monitors) is // covered by the owning agent's liveness. Nested agents fall through: // they can outlive their parent (review finding). - if ( - input.agentId !== undefined && - (taskType === undefined || MONITOR_TASK_TYPES.has(taskType)) - ) { + if (input.agentId !== undefined && agentKind === "background") { drop(input.threadId, input.taskId); return; } @@ -132,8 +135,7 @@ export function make(): ThreadBackgroundLivenessService["Service"] { drop(input.threadId, input.taskId); const state = stateFor(input.threadId); - const bucket = - taskType !== undefined && MONITOR_TASK_TYPES.has(taskType) ? state.monitors : state.agents; + const bucket = agentKind === "agent" ? state.agents : state.monitors; bucket.add(input.taskId); }, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 4f39cb34569a..0cfed96a25ab 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1906,6 +1906,163 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("promotes background codex exec tasks without promoting ordinary Bash tasks", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const relevantEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "item.started" || event.type.startsWith("task.")), + Stream.take(6), + 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: "Run checks, then have Codex review the result", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-external-agent", + uuid: "ordinary-bash-tool", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-ordinary-bash", + name: "Bash", + input: { + command: "vp test run src/example.test.ts", + run_in_background: true, + }, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "ordinary-bash", + description: "Run focused tests", + task_type: "local_bash", + tool_use_id: "tool-ordinary-bash", + uuid: "ordinary-bash-task", + session_id: "sdk-session-external-agent", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-external-agent", + uuid: "codex-bash-tool", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "tool-codex", + name: "Bash", + input: { + command: + 'command codex exec --yolo -C /repo -m gpt-5.6-sol -c model_reasoning_effort="high" -', + run_in_background: true, + }, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "codex-review", + description: "Review before merge", + task_type: "local_bash", + tool_use_id: "tool-codex", + uuid: "codex-task-started", + session_id: "sdk-session-external-agent", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "codex-review", + description: "Reviewing the changes", + uuid: "codex-task-progress", + session_id: "sdk-session-external-agent", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: "codex-review", + status: "completed", + summary: "Review complete", + uuid: "codex-task-completed", + session_id: "sdk-session-external-agent", + } as unknown as SDKMessage); + + const events = Array.from(yield* Fiber.join(relevantEventsFiber)); + const ordinaryLauncher = events.find( + (event) => event.type === "item.started" && String(event.itemId) === "tool-ordinary-bash", + ); + assert.equal(ordinaryLauncher?.type, "item.started"); + if (ordinaryLauncher?.type === "item.started") { + assert.equal(ordinaryLauncher.payload.timelineBypass, undefined); + } + const ordinaryTask = events.find( + (event) => + event.type === "task.started" && String(event.payload.taskId) === "ordinary-bash", + ); + assert.equal(ordinaryTask?.type, "task.started"); + if (ordinaryTask?.type === "task.started") { + assert.equal(ordinaryTask.payload.agentKind, undefined); + } + + const codexLauncher = events.find( + (event) => event.type === "item.started" && String(event.itemId) === "tool-codex", + ); + assert.equal(codexLauncher?.type, "item.started"); + if (codexLauncher?.type === "item.started") { + assert.equal(codexLauncher.payload.timelineBypass, true); + } + const codexStarted = events.find( + (event) => event.type === "task.started" && String(event.payload.taskId) === "codex-review", + ); + const codexProgress = events.find( + (event) => + event.type === "task.progress" && String(event.payload.taskId) === "codex-review", + ); + const codexCompleted = events.find( + (event) => + event.type === "task.completed" && String(event.payload.taskId) === "codex-review", + ); + for (const event of [codexStarted, codexProgress, codexCompleted]) { + assert.isDefined(event); + if ( + event?.type !== "task.started" && + event?.type !== "task.progress" && + event?.type !== "task.completed" + ) { + continue; + } + assert.equal(event.payload.agentKind, "agent"); + assert.equal(event.payload.taskType, "local_bash"); + assert.equal(event.payload.role, "codex"); + assert.equal(event.payload.model, "gpt-5.6-sol"); + assert.equal(event.payload.effort, "high"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 7f8ae8b7ad39..812c6b90b9ea 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -20,7 +20,7 @@ import { type SDKUserMessage, type ModelUsage, } from "@anthropic-ai/claude-agent-sdk"; -import { parseCliArgs } from "@t3tools/shared/cliArgs"; +import { parseCliArgs, tokenizeCliArgs } from "@t3tools/shared/cliArgs"; import { ApprovalRequestId, type CanonicalItemType, @@ -177,6 +177,13 @@ interface ToolInFlight { /** Owning agent when this tool ran inside a subagent (see attribution note). */ readonly agentId?: string; readonly parentToolUseId?: string; + readonly externalAgentLaunch?: ClaudeExternalAgentLaunch; +} + +interface ClaudeExternalAgentLaunch { + readonly role: "codex"; + readonly model?: string; + readonly effort?: string; } interface ClaudeTaskState { @@ -206,6 +213,7 @@ interface ClaudeTaskAgentState { * assistant snapshots (authoritative API model). */ model: string | undefined; effort: string | undefined; + agentKind: "agent" | "background" | undefined; } interface ClaudeSessionContext { @@ -229,6 +237,7 @@ interface ClaudeSessionContext { readonly inFlightTools: Map; readonly claudeTasks: Map; readonly taskAgents: Map; + readonly externalAgentLaunches: Map; /** * Last emitted workflow-member fingerprint per member slot. A coordinator * task_progress repeats the FULL member array every tick; without a @@ -705,6 +714,100 @@ function classifyToolItemType(toolName: string): CanonicalItemType { return "dynamic_tool_call"; } +function executableName(token: string | undefined): string | undefined { + return token?.split(/[\\/]/).findLast((segment) => segment.length > 0); +} + +function unquoteCliValue(value: string): string { + const trimmed = value.trim(); + const first = trimmed.at(0); + const last = trimmed.at(-1); + return trimmed.length >= 2 && first === last && (first === '"' || first === "'") + ? trimmed.slice(1, -1) + : trimmed; +} + +function codexExecArgs(tokens: ReadonlyArray): ReadonlyArray | undefined { + let cursor = tokens[0] === "command" ? 1 : 0; + if (executableName(tokens[cursor]) === "codex" && tokens[cursor + 1] === "exec") { + return tokens.slice(cursor + 2); + } + + // `codex-first` uses this fallback when the direct executable is not on PATH. + if (executableName(tokens[cursor]) !== "fnm" || tokens[cursor + 1] !== "exec") { + return undefined; + } + const commandSeparator = tokens.indexOf("--", cursor + 2); + cursor = commandSeparator + 1; + if ( + commandSeparator === -1 || + executableName(tokens[cursor]) !== "codex" || + tokens[cursor + 1] !== "exec" + ) { + return undefined; + } + return tokens.slice(cursor + 2); +} + +function codexLaunchMetadata(args: ReadonlyArray): Omit { + let model: string | undefined; + let effort: string | undefined; + for (let index = 0; index < args.length; index++) { + const token = args[index]; + if (token === undefined) continue; + if ((token === "-m" || token === "--model") && args[index + 1]) { + model = args[index + 1]; + index++; + continue; + } + if (token.startsWith("--model=")) { + model = token.slice("--model=".length); + continue; + } + + const config = + (token === "-c" || token === "--config") && args[index + 1] + ? args[++index] + : token.startsWith("--config=") + ? token.slice("--config=".length) + : undefined; + if (!config) continue; + const separator = config.indexOf("="); + if (separator === -1 || config.slice(0, separator).trim() !== "model_reasoning_effort") { + continue; + } + const parsedEffort = unquoteCliValue(config.slice(separator + 1)); + if (parsedEffort.length > 0) effort = parsedEffort; + } + return { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + }; +} + +/** Recognizes an autonomous Codex CLI run, independent of the skill or prompt that requested it. */ +function externalAgentLaunchForTool( + toolName: string, + input: Record, +): ClaudeExternalAgentLaunch | undefined { + if (classifyToolItemType(toolName) !== "command_execution" || input.run_in_background !== true) { + return undefined; + } + const commandValue = input.command ?? input.cmd; + if (typeof commandValue !== "string") { + return undefined; + } + const tokens = tokenizeCliArgs(commandValue.replace(/\\\r?\n/g, " ")); + const args = codexExecArgs(tokens); + if (!args) { + return undefined; + } + return { + role: "codex", + ...codexLaunchMetadata(args), + }; +} + function isReadOnlyToolName(toolName: string): boolean { const normalized = toolName.toLowerCase(); return ( @@ -994,6 +1097,7 @@ function taskLinkageFor( return {}; } return { + ...(agent.agentKind ? { agentKind: agent.agentKind } : {}), ...(agent.taskType ? { taskType: agent.taskType } : {}), ...(agent.owningAgentId ? { agentId: agent.owningAgentId } : {}), ...(agent.description ? { title: agent.description } : {}), @@ -2473,12 +2577,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const partialInputJson = tool.partialInputJson + event.delta.partial_json; const parsedInput = tryParseJsonRecord(partialInputJson); const detail = parsedInput ? summarizeToolRequest(tool.toolName, parsedInput) : tool.detail; + const externalAgentLaunch = parsedInput + ? (externalAgentLaunchForTool(tool.toolName, parsedInput) ?? tool.externalAgentLaunch) + : tool.externalAgentLaunch; let nextTool: ToolInFlight = { ...tool, partialInputJson, ...(parsedInput ? { input: parsedInput } : {}), ...(detail ? { detail } : {}), + ...(externalAgentLaunch ? { externalAgentLaunch } : {}), }; + if (externalAgentLaunch) { + context.externalAgentLaunches.set(tool.itemId, externalAgentLaunch); + } const nextFingerprint = parsedInput && Object.keys(parsedInput).length > 0 @@ -2520,6 +2631,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(nextTool.detail ? { detail: nextTool.detail } : {}), ...(nextTool.agentId ? { agentId: nextTool.agentId } : {}), ...(nextTool.parentToolUseId ? { parentToolUseId: nextTool.parentToolUseId } : {}), + ...(nextTool.externalAgentLaunch ? { timelineBypass: true } : {}), data: { toolName: nextTool.toolName, input: nextTool.input, @@ -2588,6 +2700,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const detail = summarizeToolRequest(toolName, toolInput); const inputFingerprint = Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; + const externalAgentLaunch = externalAgentLaunchForTool(toolName, toolInput); // Attribute tools that ran inside a subagent to their owning agent so // clients can re-home them out of the main timeline (quiet-timeline @@ -2608,8 +2721,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}), ...(owningAgentId ? { agentId: owningAgentId } : {}), ...(parentToolUseId ? { parentToolUseId } : {}), + ...(externalAgentLaunch ? { externalAgentLaunch } : {}), }; context.inFlightTools.set(index, tool); + if (externalAgentLaunch) { + context.externalAgentLaunches.set(itemId, externalAgentLaunch); + } const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ @@ -2627,6 +2744,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(tool.detail ? { detail: tool.detail } : {}), ...(tool.agentId ? { agentId: tool.agentId } : {}), ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), + ...(tool.externalAgentLaunch ? { timelineBypass: true } : {}), data: { toolName: tool.toolName, input: toolInput, @@ -2707,6 +2825,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(tool.detail ? { detail: tool.detail } : {}), ...(tool.agentId ? { agentId: tool.agentId } : {}), ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), + ...(tool.externalAgentLaunch ? { timelineBypass: true } : {}), data: toolData, }, providerRefs: nativeProviderRefs(context, { @@ -2761,6 +2880,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(tool.detail ? { detail: tool.detail } : {}), ...(tool.agentId ? { agentId: tool.agentId } : {}), ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), + ...(tool.externalAgentLaunch ? { timelineBypass: true } : {}), data: toolData, }, providerRefs: nativeProviderRefs(context, { @@ -2806,6 +2926,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( owningAgentId: existing?.owningAgentId, model: existing?.model, effort: existing?.effort, + agentKind: existing?.agentKind, }); } } @@ -2821,6 +2942,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + if (toolResult.isError && tool.externalAgentLaunch) { + context.externalAgentLaunches.delete(tool.itemId); + } + context.inFlightTools.delete(index); } }); @@ -3158,27 +3283,38 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) : undefined; const owningAgentId = launchingTool?.agentId; - // Model/effort: the Agent tool's input carries explicit overrides; - // absent ones inherit the session's selection (SDK behavior). - // Subagent assistant snapshots later refine model with the - // authoritative API id. AgentInput.effort may be a named level or an - // integer. + const externalAgentLaunch = message.tool_use_id + ? (launchingTool?.externalAgentLaunch ?? + context.externalAgentLaunches.get(message.tool_use_id)) + : undefined; + if (message.tool_use_id && externalAgentLaunch) { + context.externalAgentLaunches.delete(message.tool_use_id); + } + // Native Agent tools inherit the Claude session when they omit an + // override. External Codex tasks only advertise values explicitly + // present in their CLI command; inheriting Claude's selection would + // mislabel them. Native snapshots can later refine their API model. const launchInput = launchingTool?.input; - const model = - trimmedString(launchInput?.model) ?? trimmedString(context.session.model ?? undefined); + const model = externalAgentLaunch + ? externalAgentLaunch.model + : (trimmedString(launchInput?.model) ?? + trimmedString(context.session.model ?? undefined)); const rawLaunchEffort = launchInput?.effort; - const effort = - trimmedString(rawLaunchEffort) ?? - (typeof rawLaunchEffort === "number" && Number.isFinite(rawLaunchEffort) - ? String(rawLaunchEffort) - : context.currentEffort); + const effort = externalAgentLaunch + ? externalAgentLaunch.effort + : (trimmedString(rawLaunchEffort) ?? + (typeof rawLaunchEffort === "number" && Number.isFinite(rawLaunchEffort) + ? String(rawLaunchEffort) + : context.currentEffort)); + const subagentType = externalAgentLaunch?.role ?? message.subagent_type; + const agentKind = externalAgentLaunch ? ("agent" as const) : undefined; // Remember the agent identity so every later task.* payload for this // taskId is self-describing (identity must survive activity retention). context.taskAgents.set(message.task_id, { taskId: message.task_id, toolUseId: message.tool_use_id, description: message.description, - subagentType: message.subagent_type, + subagentType, taskType: message.task_type, workflowName: message.workflow_name, skipTranscript: message.skip_transcript === true, @@ -3186,6 +3322,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( owningAgentId, model, effort, + agentKind, }); context.liveTaskIds.add(message.task_id); yield* offerRuntimeEvent({ @@ -3195,9 +3332,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( taskId: RuntimeTaskId.make(message.task_id), description: message.description, ...(message.task_type ? { taskType: message.task_type } : {}), + ...(agentKind ? { agentKind } : {}), ...(owningAgentId ? { agentId: owningAgentId } : {}), ...(message.description ? { title: message.description } : {}), - ...(message.subagent_type ? { role: message.subagent_type } : {}), + ...(subagentType ? { role: subagentType } : {}), ...(model ? { model } : {}), ...(effort ? { effort } : {}), ...(message.tool_use_id ? { toolUseId: message.tool_use_id } : {}), @@ -3780,6 +3918,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const inFlightTools = new Map(); const claudeTasks = new Map(); const taskAgents = new Map(); + const externalAgentLaunches = new Map(); const workflowMemberFingerprints = new Map(); const liveTaskIds = new Set(); @@ -4235,6 +4374,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( inFlightTools, claudeTasks, taskAgents, + externalAgentLaunches, workflowMemberFingerprints, liveTaskIds, turnState: undefined, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f1ea28dda0ae..fa2264a621f4 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1943,6 +1943,33 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { expect(entries[0]!.agentSpawn?.agentTaskIds).toEqual(["child-1", "child-2"]); }); + it("shows a promoted Claude-launched Codex task as an agent CTA without its Bash row", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + kind: "tool.started", + summary: "Bash started", + tone: "tool", + payload: { itemType: "command_execution", timelineBypass: true }, + turnId: "turn-codex", + }), + makeActivity({ + kind: "task.started", + summary: "Task started", + tone: "info", + payload: { + taskId: "codex-1", + taskType: "local_bash", + agentKind: "agent", + title: "Review before merge", + role: "codex", + }, + turnId: "turn-codex", + }), + ]); + expect(entries).toHaveLength(1); + expect(entries[0]!.agentSpawn?.agentTaskIds).toEqual(["codex-1"]); + }); + it("timelineBypass non-agent rows (background shells) stay suppressed", () => { const entries = deriveWorkLogEntries([ makeActivity({ diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 12bf1d5d77fe..9ea637659424 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -4,6 +4,18 @@ After Claude finishes a turn, it may suggest a likely follow-up in the empty mes Press Tab to copy that suggestion into the composer; T3 Code does not send it until you submit it. Prompt suggestions are on by default and can be changed for each Claude provider in Settings. +## Codex Agents Launched By Claude + +When Claude starts `codex exec` as a background Bash command, T3 Code presents that task in the +Agents view and groups launches into the same agent summary shown for Claude's own subagents. +Ordinary background commands still appear as Monitoring. + +Recognition is based on the command Claude actually launches, not on a particular skill or phrase. +A skill can teach Claude when and how to delegate to Codex, but T3 Code does not require one. When +the command includes an explicit Codex model or reasoning effort, the Agents view displays it. +Claude's task lifecycle supplies status and progress; Codex token and tool counts are not shown +unless that lifecycle reports them. + This guide is for people who want to use more than one Claude setup in T3 Code. For Codex, see [Codex](./providers-codex.md). For first-time setup, see [Install T3 Code](./install.md). diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index ceb40517550e..693a33f022fb 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -586,6 +586,35 @@ describe("background task exclusion", () => { expect(agents.map((agent) => agent.id)).toEqual(["agent-1"]); }); + it("includes an explicitly promoted external agent even when its SDK task is a shell", () => { + const agents = fold([ + activity("task.started", { + taskId: "codex-1", + taskType: "local_bash", + agentKind: "agent", + title: "Review before merge", + role: "codex", + model: "gpt-5.6-sol", + effort: "high", + }), + activity("task.completed", { + taskId: "codex-1", + taskType: "local_bash", + agentKind: "agent", + status: "completed", + }), + ]); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + id: "codex-1", + title: "Review before merge", + role: "codex", + model: "gpt-5.6-sol", + effort: "high", + status: "completed", + }); + }); + it("rows without a taskType stay in the roster (workflow members, Codex children)", () => { const agents = fold([ activity("task.progress", { taskId: "wf-1:wf:0", status: "running", parentAgentId: "wf-1" }), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 6b11e8dbca8a..1c4cf03f7561 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -417,6 +417,8 @@ export const ItemLifecyclePayload = Schema.Struct({ */ agentId: Schema.optional(TrimmedNonEmptyStringSchema), parentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema), + /** Provider-recognized launcher/chatter that belongs off the parent timeline. */ + timelineBypass: Schema.optional(Schema.Boolean), }); export type ItemLifecyclePayload = typeof ItemLifecyclePayload.Type;