From ceef003733392d4d40ff05ace66abc8ec3a47778 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:43:53 +0000 Subject: [PATCH] sync(provenance): import upstream stack C1..C2 Imports the tree delta from the fork/candidates tree recorded in the fork-dev/2026-08-06.1 checkpoint to a rebuilt fork/candidates carrying upstream main a2ca89aa1. Five upstream commits enter the product: a2ca89aa feat: native subagent & workflow observability (#5219) 990bb0b6 fix: reconnect faster after remote server updates (#5404) 7251f1a1 Prevent terminal loading flash (#5432) 30e47153 fix(web): preserve terminal font size when splitting (#5444) de592a00 Enrich terminal font previews (#5428) importedCandidatesCommit: 9655a9ba955197361044ef6f8f97e35841ff779e importedCandidatesTree: 50f9bfab717c30a8ea90d52060349e209502d116 importedUpstreamCommit: a2ca89aa10f13a2222e08afd98c66285121d5ba2 previousCandidatesTree: 9b4cd3e1c774c3c436e43305c151edf596b2936a Seven files conflicted against the fork/dev product tree. Most are independent additions on both sides and resolve as unions: upstream's backgroundLiveness alongside identity's originSource/participantSummaries, upstream's agent-spawn CTA rows alongside the imported user-input Q&A timeline. Two needed more than a union. apps/mobile threadActivity.ts: upstream's isAgentInternalActivity skip guard must run before identity's resolved-user-input enrichment. Concatenating the sides in the other order would enrich and push agent-internal rows that upstream intends to drop. apps/web Sidebar.logic.ts: the 3-way merge welded upstream's hasPlanReadyPrompt condition onto the "Wake Required" return body, so a plan-ready thread would have rendered as Wake Required and no thread could ever reach Plan Ready. Both branches are restored with their own bodies. The status rank map is merged onto upstream's new scale with Wake Required kept at the Working/Connecting tier, matching its relative position before the import. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- .../components/AppearancePreviews.tsx | 53 +- apps/mobile/src/lib/threadActivity.test.ts | 36 + apps/mobile/src/lib/threadActivity.ts | 94 +- .../OrchestrationEngineHarness.integration.ts | 3 +- apps/server/src/auth/RpcAuthorization.ts | 1 + .../ActivityPayloadProjection.test.ts | 63 ++ .../Layers/CheckpointReactor.test.ts | 3 + .../Layers/OrchestrationEngine.test.ts | 5 + .../Layers/ProjectionPipeline.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.test.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 11 + .../Layers/ProviderCommandReactor.test.ts | 3 + ...viderRuntimeIngestion.grokSegments.test.ts | 4 + .../Layers/ProviderRuntimeIngestion.test.ts | 13 +- .../Layers/ProviderRuntimeIngestion.ts | 177 +++- .../ThreadBackgroundLiveness.test.ts | 173 ++++ .../orchestration/ThreadBackgroundLiveness.ts | 160 +++ apps/server/src/orchestration/runtimeLayer.ts | 6 +- .../orchestration/workflowScriptQuery.test.ts | 75 ++ .../src/orchestration/workflowScriptQuery.ts | 124 +++ .../src/provider/Layers/ClaudeAdapter.test.ts | 242 +++++ .../src/provider/Layers/ClaudeAdapter.ts | 598 +++++++++++- .../src/provider/Layers/CodexAdapter.ts | 268 +++++ .../CodexCollabRuntime.integration.test.ts | 248 +++++ .../provider/Layers/CodexCollabWire.test.ts | 180 ++++ .../provider/Layers/CodexSessionRuntime.ts | 491 +++++++++- .../testFixtures/codexCollabMockPeer.mjs | 93 ++ .../testFixtures/codexCollabMockPeer.sh | 6 + .../testFixtures/codexMultiAgentWire.json | 445 +++++++++ apps/server/src/server.ts | 9 +- apps/server/src/ws.ts | 7 + apps/web/src/components/AgentsPanel.tsx | 568 +++++++++++ apps/web/src/components/ChatView.tsx | 116 ++- apps/web/src/components/RightPanelTabs.tsx | 21 +- apps/web/src/components/Sidebar.logic.ts | 73 +- apps/web/src/components/SidebarV2.tsx | 47 +- apps/web/src/components/board/Board.logic.ts | 1 + .../components/chat/MessagesTimeline.logic.ts | 46 +- .../src/components/chat/MessagesTimeline.tsx | 132 ++- .../settings/SettingsFontPreviews.tsx | 25 +- apps/web/src/rightPanelStore.ts | 17 +- apps/web/src/session-logic.test.ts | 227 ++++- apps/web/src/session-logic.ts | 204 +++- apps/web/src/terminal/ghostty/surface.test.ts | 19 - apps/web/src/terminal/ghostty/surface.ts | 60 +- packages/client-runtime/package.json | 4 + .../client-runtime/src/state/orchestration.ts | 7 + .../client-runtime/src/state/server.test.ts | 55 ++ packages/client-runtime/src/state/server.ts | 48 +- .../src/state/subagentRuntime.test.ts | 772 +++++++++++++++ .../src/state/subagentRuntime.ts | 924 ++++++++++++++++++ packages/contracts/src/orchestration.ts | 59 ++ .../contracts/src/providerRuntime.test.ts | 21 +- packages/contracts/src/providerRuntime.ts | 175 +++- packages/contracts/src/rpc.ts | 11 + scripts/mobile-showcase-environment.ts | 24 +- 56 files changed, 7077 insertions(+), 176 deletions(-) create mode 100644 apps/server/src/orchestration/ActivityPayloadProjection.test.ts create mode 100644 apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts create mode 100644 apps/server/src/orchestration/ThreadBackgroundLiveness.ts create mode 100644 apps/server/src/orchestration/workflowScriptQuery.test.ts create mode 100644 apps/server/src/orchestration/workflowScriptQuery.ts create mode 100644 apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts create mode 100644 apps/server/src/provider/Layers/CodexCollabWire.test.ts create mode 100644 apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs create mode 100755 apps/server/src/provider/testFixtures/codexCollabMockPeer.sh create mode 100644 apps/server/src/provider/testFixtures/codexMultiAgentWire.json create mode 100644 apps/web/src/components/AgentsPanel.tsx create mode 100644 packages/client-runtime/src/state/subagentRuntime.test.ts create mode 100644 packages/client-runtime/src/state/subagentRuntime.ts diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx index fe960109634..b111035c01f 100644 --- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -1,4 +1,11 @@ -import { Platform, ScrollView, View, useColorScheme } from "react-native"; +import { + Platform, + ScrollView, + type StyleProp, + type TextStyle, + View, + useColorScheme, +} from "react-native"; import { AppText as Text } from "../../../../components/AppText"; import { @@ -54,14 +61,48 @@ export function TerminalAppearancePreview(props: { readonly fontSize: number }) fontSize: props.fontSize, lineHeight, } as const; + // AppText stamps the sans font on every node, so nested spans must + // re-apply the terminal font instead of relying on inheritance, exactly + // like the code preview's tokens below. + const span = (color: string, extra?: TextStyle): StyleProp => [ + lineStyle, + { color, ...extra }, + ]; return ( - $ npm run dev - ✓ Ready in 430ms - - Local: http://localhost:3000{" "} - + + + t3code + git:( + main + ) + + vpr dev + + + VITE v7.1.1 + ready in + 1.24s + + + + Local: + + http://127.0.0.1:5173/ + + + + ✓ 85 passed + △ 2 warnings + ✗ 0 failed + + + + {" READY "} + + watching for changes{" "} + ); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 78fc625cefb..0110f3a87d2 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -764,3 +764,39 @@ describe("promoteSteeredQueuedMessages", () => { expect(promoteSteeredQueuedMessages(persisted, new Set([steeredId])).messages).toHaveLength(1); }); }); + +describe("quiet timeline: nested agents", () => { + it("keeps a nested agent's terminal row but hides its background work", () => { + const thread = makeThread({ + id: ThreadId.make("thread-nested"), + projectId: ProjectId.make("project-1"), + title: "Nested agents", + activities: [ + // A subagent's own shell: internal, covered by the owner's liveness. + makeActivity({ + id: EventId.make("shell-done"), + kind: "task.completed", + summary: "Task completed", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { taskId: "sh-1", agentId: "owner", agentKind: "background" }, + }), + // A nested AGENT's completion: mobile has no Agents sheet, so this + // terminal row is the only signal it ever finished. + makeActivity({ + id: EventId.make("nested-done"), + kind: "task.completed", + summary: "Task completed", + createdAt: "2026-04-01T00:00:03.000Z", + payload: { taskId: "n-1", agentId: "owner", agentKind: "agent" }, + }), + ], + }); + + const feed = buildThreadFeed(thread); + const ids = feed.flatMap((entry) => + entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [], + ); + expect(ids).toContain("nested-done"); + expect(ids).not.toContain("shell-done"); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index a4648c8a5aa..7c8158577a0 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -90,6 +90,8 @@ interface WorkLogEntry { interface DerivedWorkLogEntry extends WorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; + /** Grouping key for subagent lifecycle rows (one row per agent). */ + taskId?: string; } type RawThreadFeedEntry = @@ -302,6 +304,63 @@ function resolvePendingUserInputAnswer( return normalizeDraftAnswer(draft?.selectedOptionLabel); } +/** Codex children settle via task.updated (idle/failed/interrupted), never + * task.completed — these rows are mobile's only terminal signal for them. */ +const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet = new Set([ + "idle", + "completed", + "failed", + "cancelled", + "interrupted", +]); + +function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean { + if (activity.kind !== "task.updated") { + return false; + } + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + return ( + payload?.timelineBypass === true && + typeof payload.status === "string" && + MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status) + ); +} + +/** + * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal + * activity lives in the Agents sheet, not the work log. Terminal rows are + * kept — with no Agents surface on mobile they are the terminal signal + * (a surface that hides rows must keep its own terminal signal). That means + * task.completed (Claude) AND terminal bypassed task.updated (Codex, whose + * children never emit task.completed — review finding). + */ +function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + if (!payload) { + return false; + } + const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalBypassUpdate(activity); + if (payload.timelineBypass === true && !isTerminalTaskRow) { + return true; + } + // agentId marks ownership, not "hide me": a NESTED AGENT's terminal row is + // the only signal mobile gets (no Agents sheet), so it stays. Only an + // agent's own background work (stamped "background") is internal — same + // rule as web (review finding: hiding on agentId alone dropped nested + // completions with no replacement UI). + const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0; + if (!ownedByAgent) { + return false; + } + return !(isTerminalTaskRow && payload.agentKind === "agent"); +} + function deriveWorkLogEntries( activities: ReadonlyArray, ): DerivedWorkLogEntry[] { @@ -313,9 +372,13 @@ function deriveWorkLogEntries( for (const activity of ordered) { if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; + // Terminal bypassed updates pass: Codex children's only terminal signal. + if (activity.kind === "task.updated" && !isTerminalBypassUpdate(activity)) continue; + if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; + if (isAgentInternalActivity(activity)) continue; const entry = toDerivedWorkLogEntry(activity); const resolvedUserInput = resolvedUserInputs.get(activity.id); if (resolvedUserInput) { @@ -347,7 +410,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); - const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; + // task.updated included: terminal bypassed updates (Codex children's only + // terminal signal) must carry task identity so they collapse per child + // instead of stacking anonymous "Task idle" rows. + const isTaskActivity = + activity.kind === "task.progress" || + activity.kind === "task.completed" || + activity.kind === "task.updated"; const taskSummary = isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 ? payload.summary @@ -360,10 +429,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo ? payload.detail : null; const taskLabel = taskSummary || taskDetailAsLabel; + const taskId = + isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0 + ? payload.taskId + : undefined; const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, turnId: activity.turnId, + ...(taskId ? { taskId } : {}), label: taskLabel || activity.summary, tone: activity.kind === "task.progress" @@ -428,7 +502,25 @@ function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { const collapsed: DerivedWorkLogEntry[] = []; + // Subagent rows collapse by identity, not adjacency (quiet-timeline + // guarantee; mirrors web's session-logic). + const taskRowIndex = new Map(); for (const entry of entries) { + const isTaskRow = + entry.taskId !== undefined && + (entry.activityKind === "task.progress" || + entry.activityKind === "task.completed" || + entry.activityKind === "task.updated"); + if (isTaskRow && entry.taskId !== undefined) { + const existingIndex = taskRowIndex.get(entry.taskId); + if (existingIndex !== undefined) { + collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry); + continue; + } + taskRowIndex.set(entry.taskId, collapsed.length); + collapsed.push(entry); + continue; + } const previous = collapsed.at(-1); if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c2b4db8434e..0f1503eae00 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -50,6 +50,7 @@ import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityRe import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts"; import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; @@ -307,7 +308,7 @@ export const makeOrchestrationIntegrationHarness = ( providerLayer, providerSessionDirectoryLayer, RuntimeReceiptBusTest, - ); + ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer)); const serverSettingsLayer = ServerSettingsService.layerTest(); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 20751fb826c..decb4b1154d 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -22,6 +22,7 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; */ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, + [ORCHESTRATION_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getThreadActivities]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts new file mode 100644 index 00000000000..7ea1e3ea0ed --- /dev/null +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import { projectActivityPayload } from "./ActivityPayloadProjection.ts"; + +function activity(payload: Record): OrchestrationThreadActivity { + return { + id: "activity-1", + tone: "tool", + kind: "tool.completed", + summary: "Tool", + payload, + turnId: null, + createdAt: "2026-08-01T10:00:00.000Z", + } as unknown as OrchestrationThreadActivity; +} + +/** + * Wire-survival regression: the slimming pass rewrites payload.data but must + * never strip the top-level per-agent fields the subagent fold depends on. + * If slimming ever moves to an allowlist over the whole payload, these + * assertions are the tripwire. + */ +describe("projectActivityPayload agent-field survival", () => { + it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { + const projected = projectActivityPayload( + activity({ + itemType: "command_execution", + agentId: "task-123", + parentToolUseId: "toolu_abc", + data: { + toolName: "Bash", + input: { command: "ls" }, + command: "ls", + rawOutput: { content: "x".repeat(10) }, + somethingClientNeverReads: { big: "blob" }, + }, + }), + ); + const payload = projected.payload as Record; + expect(payload.agentId).toBe("task-123"); + expect(payload.parentToolUseId).toBe("toolu_abc"); + // Slimming itself still applies to data. + const data = payload.data as Record; + expect(data.somethingClientNeverReads).toBeUndefined(); + }); + + it("passes task lifecycle payloads (no data field) through untouched", () => { + const source = activity({ + taskId: "task-9", + title: "Audit auth", + role: "explorer", + model: "opus", + effort: "high", + workflowName: "audit-flow", + phases: [{ index: 0, title: "Audit" }], + typedUsage: { totalTokens: 1200 }, + runHandles: { runId: "run-1", scriptPath: "/tmp/wf.js" }, + timelineBypass: true, + }); + const projected = projectActivityPayload(source); + expect(projected.payload).toEqual(source.payload); + }); +}); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index a1cee753910..fc08862dbb9 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -39,6 +39,7 @@ import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -295,6 +296,7 @@ describe("CheckpointReactor", () => { ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -302,6 +304,7 @@ describe("CheckpointReactor", () => { Layer.provide(SqlitePersistenceMemory), ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index c1f504618b4..dcb66555cac 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -31,6 +31,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline, @@ -55,6 +56,7 @@ async function createOrchestrationSystem() { ), OrchestrationProjectionSnapshotQueryLive, ).pipe( + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), @@ -895,6 +897,7 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -1000,6 +1003,7 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -1143,6 +1147,7 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index ec3674dd4c0..f0aa2872951 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -31,6 +31,7 @@ import { OrchestrationProjectionPipelineLive, } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; @@ -2796,6 +2797,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta const engineLayer = it.layer( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index f25fa4ac92d..03edc5c6330 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -18,6 +18,7 @@ import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); @@ -28,6 +29,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), @@ -447,6 +449,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { hasPendingApprovals: true, hasPendingUserInput: false, hasActionableProposedPlan: false, + backgroundLiveness: null, }, ]); @@ -2318,6 +2321,7 @@ it.effect( () => { const resolveCalls: string[] = []; const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provideMerge( Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve: (cwd: string) => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e2a95b9f8d7..fda437cae5c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -47,6 +47,7 @@ import { type ProjectionRepositoryError, } from "../../persistence/Errors.ts"; import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; +import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; @@ -388,6 +389,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st } const makeProjectionSnapshotQuery = Effect.gen(function* () { + const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const sql = yield* SqlClient.SqlClient; const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const repositoryIdentityResolutionConcurrency = 4; @@ -2073,6 +2075,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { row.participantSummaries !== undefined ? { participantSummaries: row.participantSummaries } : {}), + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), } satisfies OrchestrationThreadShell) : Result.failVoid, ), @@ -2219,6 +2224,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ...(row.participantSummaries !== null && row.participantSummaries !== undefined ? { participantSummaries: row.participantSummaries } : {}), + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), }), ), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", @@ -2529,6 +2537,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threadRow.value.participantSummaries !== undefined ? { participantSummaries: threadRow.value.participantSummaries } : {}), + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + threadRow.value.threadId, + ), } satisfies OrchestrationThreadShell); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ae60a2abefc..111db22cbf6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -55,6 +55,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import { providerErrorLabel, providerErrorLabelFromInstanceHint, @@ -400,6 +401,7 @@ describe("ProviderCommandReactor", () => { const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -407,6 +409,7 @@ describe("ProviderCommandReactor", () => { Layer.provide(persistenceLayer), ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(persistenceLayer), ); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts index 09d2a31eab5..c3eefeb6b11 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts @@ -53,6 +53,7 @@ import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { assistantItemId } from "../../provider/acp/AcpSessionRuntime.ts"; @@ -307,6 +308,9 @@ describe("ProviderRuntimeIngestion Grok multi-segment assistant bubbles", () => const layer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), + // Single shared liveness instance across ingestion (writer), the + // engine, and the snapshot query (reader). + Layer.provideMerge(ThreadBackgroundLiveness.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 989b2487f3d..2c174a203d2 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -44,6 +44,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; @@ -238,6 +239,9 @@ describe("ProviderRuntimeIngestion", () => { const layer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), + // Single shared liveness instance across ingestion (writer), the + // engine, and the snapshot query (reader). + Layer.provideMerge(ThreadBackgroundLiveness.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), @@ -3370,7 +3374,8 @@ describe("ProviderRuntimeIngestion", () => { (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-started", ); const progress = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-progress", + (activity: ProviderRuntimeTestActivity) => + activity.id === "task-progress:thread-1:turn-task-1", ); const completed = thread.activities.find( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-completed", @@ -3454,7 +3459,8 @@ describe("ProviderRuntimeIngestion", () => { ); const progress = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-progress", + (activity: ProviderRuntimeTestActivity) => + activity.id === "task-progress:thread-1:named-task-1", ); const completed = thread.activities.find( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed", @@ -3545,7 +3551,8 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread(harness.readModel, (entry) => entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-progress", + (activity: ProviderRuntimeTestActivity) => + activity.id === "task-progress:thread-1:swept-task-1", ), ); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 057c99aad7e..25b813cccc7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -7,6 +7,8 @@ import { type OrchestrationMessage, type OrchestrationProposedPlanId, CheckpointRef, + classifyTaskAgentKind, + EventId, isToolLifecycleItemType, ThreadId, type ThreadTokenUsageSnapshot, @@ -35,6 +37,7 @@ import { ProjectionQueuedMessageRepository } from "../../persistence/Services/Pr import { ProjectionQueuedMessageRepositoryLive } from "../../persistence/Layers/ProjectionQueuedMessages.ts"; import { isGitRepository } from "../../git/Utils.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ProviderRuntimeIngestionService, @@ -345,6 +348,52 @@ function requestKindFromCanonicalRequestType( } } +/** + * Copies the optional TaskAgentLinkage bundle from a task.* runtime payload + * into the persisted activity payload. Identity fields ride on every row so + * client folds survive activity retention; absent fields stay absent. + */ +function taskLinkageActivityFields(payload: Record): Record { + 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, + }), + }; + for (const key of [ + "taskType", + "agentId", + "title", + "role", + "model", + "effort", + "toolUseId", + "parentAgentId", + "workflowName", + "agentIndex", + "phaseIndex", + "phaseTitle", + "phases", + "attempt", + "runHandles", + "outputFile", + "agentPath", + "timelineBypass", + "typedUsage", + "status", + "error", + ] as const) { + if (payload[key] !== undefined) { + fields[key] = payload[key]; + } + } + return fields; +} + export function runtimeEventToActivities( event: ProviderRuntimeEvent, taskTitle?: string, @@ -543,6 +592,7 @@ export function runtimeEventToActivities( ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } : {}), + ...taskLinkageActivityFields(event.payload as Record), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -553,7 +603,16 @@ export function runtimeEventToActivities( case "task.progress": { return [ { - id: event.eventId, + // Stable per-task id: progress is "latest state", not history, so + // each tick REPLACES the last via the activity upsert (PK + the + // replace-by-id apply in projector and client reducer). Keeps one + // progress row per task instead of thousands, so a large fleet's + // ticks can no longer evict its own start/terminal rows out of + // the 500-row retention window. Thread-scoped: activity_id is a + // GLOBAL primary key and Claude task ids are session-local, so a + // bare taskId could collide across threads and steal another + // thread's row (review finding). + id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`), createdAt: event.createdAt, tone: "info", kind: "task.progress", @@ -570,6 +629,71 @@ export function runtimeEventToActivities( ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + ...taskLinkageActivityFields(event.payload as Record), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "task.updated": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: event.payload.status === "failed" ? "error" : "info", + kind: "task.updated", + summary: + event.payload.status === "failed" + ? "Task failed" + : event.payload.status + ? `Task ${event.payload.status}` + : "Task updated", + payload: { + taskId: event.payload.taskId, + ...(event.payload.description + ? { detail: truncateDetail(event.payload.description) } + : {}), + ...(event.payload.endedAt ? { endedAt: event.payload.endedAt } : {}), + ...(event.payload.isBackgrounded !== undefined + ? { isBackgrounded: event.payload.isBackgrounded } + : {}), + ...taskLinkageActivityFields(event.payload as Record), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "tool.progress": { + // Only agent-owned heartbeats are persisted: they feed the owning + // agent's activity line. Parent-conversation tool progress stays + // ephemeral (item lifecycle already covers it). + if (event.payload.taskId === undefined) { + return []; + } + return [ + { + // Same stable-id treatment as task.progress: a heartbeat is + // "what is this agent doing right now", so one row per task + // (thread-scoped for the same global-PK collision reason). + id: EventId.make(`tool-progress:${event.threadId}:${event.payload.taskId}`), + createdAt: event.createdAt, + tone: "info", + kind: "tool.progress", + summary: event.payload.toolName ?? "Tool progress", + payload: { + taskId: event.payload.taskId, + ...(event.payload.toolName ? { toolName: event.payload.toolName } : {}), + ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}), + ...(event.payload.elapsedSeconds !== undefined + ? { elapsedSeconds: event.payload.elapsedSeconds } + : {}), + ...(event.payload.parentToolUseId + ? { parentToolUseId: event.payload.parentToolUseId } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -603,6 +727,7 @@ export function runtimeEventToActivities( } : {}), ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + ...taskLinkageActivityFields(event.payload as Record), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -668,6 +793,10 @@ export function runtimeEventToActivities( ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), + ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), + ...(event.payload.parentToolUseId + ? { parentToolUseId: event.payload.parentToolUseId } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -690,6 +819,10 @@ export function runtimeEventToActivities( itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), + ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), + ...(event.payload.parentToolUseId + ? { parentToolUseId: event.payload.parentToolUseId } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -711,6 +844,10 @@ export function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), + ...(event.payload.parentToolUseId + ? { parentToolUseId: event.payload.parentToolUseId } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -726,6 +863,7 @@ export function runtimeEventToActivities( } const make = Effect.gen(function* () { + const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -1928,6 +2066,43 @@ const make = Effect.gen(function* () { yield* rememberTaskDescription(thread.id, event.payload.taskId, description); } } + // Sidebar background liveness: fed from the same lifecycle stream, + // read by the shell query at mapping time (no persistence). + switch (event.type) { + case "task.started": + case "task.progress": + case "task.updated": + case "task.completed": { + const payload = event.payload as { + taskId: string; + taskType?: string; + status?: string; + agentId?: string; + }; + threadBackgroundLiveness.recordTaskLiveness({ + threadId: thread.id, + taskId: payload.taskId, + taskType: payload.taskType, + status: payload.status, + agentId: payload.agentId, + kind: + event.type === "task.started" + ? "started" + : event.type === "task.progress" + ? "progress" + : event.type === "task.updated" + ? "updated" + : "completed", + }); + break; + } + case "session.exited": + threadBackgroundLiveness.clearThreadLiveness(thread.id); + break; + default: + break; + } + let taskTitle: string | undefined; if (event.type === "task.completed") { taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts new file mode 100644 index 00000000000..0c4841e8119 --- /dev/null +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; + +describe("ThreadBackgroundLiveness", () => { + it("agents present as working; monitors as monitoring; agents win", () => { + const liveness = ThreadBackgroundLiveness.make(); + const threadId = "t-live-1"; + liveness.recordTaskLiveness({ + threadId, + taskId: "m1", + taskType: "local_bash", + status: undefined, + kind: "started", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("monitoring"); + liveness.recordTaskLiveness({ + threadId, + taskId: "a1", + taskType: "subagent", + status: undefined, + kind: "started", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working"); + liveness.recordTaskLiveness({ + threadId, + taskId: "a1", + taskType: "subagent", + status: "completed", + kind: "completed", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("monitoring"); + liveness.recordTaskLiveness({ + threadId, + taskId: "m1", + taskType: "local_bash", + status: "completed", + kind: "completed", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); + }); + + it("terminal rows without a taskType still clear monitor entries", () => { + const liveness = ThreadBackgroundLiveness.make(); + const threadId = "t-live-2"; + liveness.recordTaskLiveness({ + threadId, + taskId: "m1", + taskType: "local_bash", + status: undefined, + kind: "started", + }); + // Terminal tick arrives with no taskType (common on task.completed). + liveness.recordTaskLiveness({ + threadId, + taskId: "m1", + taskType: undefined, + 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"; + liveness.recordTaskLiveness({ + threadId, + taskId: "n1", + taskType: "local_agent", + status: undefined, + kind: "started", + agentId: "owner", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working"); + liveness.recordTaskLiveness({ + threadId, + taskId: "n1", + taskType: "local_agent", + status: "completed", + kind: "completed", + agentId: "owner", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); + }); + + it("untyped rows count as agents; idle is not live; agent-owned tasks are ignored", () => { + const liveness = ThreadBackgroundLiveness.make(); + const threadId = "t-live-3"; + liveness.recordTaskLiveness({ + threadId, + taskId: "wf:1", + taskType: undefined, + status: "running", + kind: "progress", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working"); + liveness.recordTaskLiveness({ + threadId, + taskId: "wf:1", + taskType: undefined, + status: "idle", + kind: "updated", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); + liveness.recordTaskLiveness({ + threadId, + taskId: "sh:1", + taskType: "local_bash", + status: undefined, + kind: "started", + agentId: "owner", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); + }); + + it("reclassification moves a task between buckets instead of duplicating it", () => { + const liveness = ThreadBackgroundLiveness.make(); + const threadId = "t-live-reclass"; + // First seen without a taskType: counts as an agent. + liveness.recordTaskLiveness({ + threadId, + taskId: "x1", + taskType: undefined, + status: "running", + kind: "started", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working"); + // Later transition reveals it's a shell: downgrade to monitoring, not + // a stale duplicate pinning "working". + liveness.recordTaskLiveness({ + threadId, + taskId: "x1", + taskType: "local_bash", + status: "running", + kind: "progress", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("monitoring"); + // Turning out to be inert or agent-owned drops the prior entry too. + liveness.recordTaskLiveness({ + threadId, + taskId: "x1", + taskType: "local_bash", + status: "running", + kind: "progress", + agentId: "owner", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); + }); + + it("plan tasks are inert; clear removes everything; instances are isolated", () => { + const a = ThreadBackgroundLiveness.make(); + const b = ThreadBackgroundLiveness.make(); + a.recordTaskLiveness({ + threadId: "t", + taskId: "p1", + taskType: "plan", + status: undefined, + kind: "started", + }); + expect(a.getThreadBackgroundLiveness("t")).toBeNull(); + a.recordTaskLiveness({ + threadId: "t", + taskId: "a1", + taskType: "local_workflow", + status: undefined, + kind: "started", + }); + expect(a.getThreadBackgroundLiveness("t")).toBe("working"); + expect(b.getThreadBackgroundLiveness("t")).toBeNull(); + a.clearThreadLiveness("t"); + expect(a.getThreadBackgroundLiveness("t")).toBeNull(); + }); +}); diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts new file mode 100644 index 00000000000..8563e7665fb --- /dev/null +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts @@ -0,0 +1,160 @@ +/** + * ThreadBackgroundLivenessService - in-memory per-thread background liveness + * for the sidebar status pill. + * + * The turn can settle while native background work runs on (subagent fleets, + * workflow runs, Monitor watch loops); the shell previously showed nothing. + * Ingestion records task lifecycle transitions and the shell query reads the + * derived state at mapping time — no persistence, no migration. After a + * server restart the registry is empty until new task events arrive, which + * matches reality: orphaned background work is not live. + * + * "monitoring" is reserved for watch loops (monitor tasks and background + * shells) when they are the ONLY live work; any agent work presents as + * "working". + * + * @module ThreadBackgroundLivenessService + */ +import { INERT_TASK_TYPES, MONITOR_TASK_TYPES } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export type ThreadBackgroundLiveness = "working" | "monitoring" | null; + +interface ThreadLivenessState { + readonly agents: Set; + 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. + +const TERMINAL_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "stopped", + "cancelled", + "interrupted", +]); + +export class ThreadBackgroundLivenessService extends Context.Service< + ThreadBackgroundLivenessService, + { + /** + * Feed one task lifecycle transition. taskType may be absent on + * synthesized rows (workflow members, Codex children) — those count as + * agents. agentId marks a task launched from inside a subagent: its + * internal shells are covered by the owning agent's liveness, but a + * NESTED AGENT (agentId + agent-flavored taskType) still counts — it + * can outlive its parent and must keep the thread Working. + */ + readonly recordTaskLiveness: (input: { + readonly threadId: string; + readonly taskId: string; + readonly taskType: string | undefined; + readonly status: string | undefined; + readonly kind: "started" | "progress" | "updated" | "completed"; + readonly agentId?: string | undefined; + }) => void; + + /** Session death orphans all of a thread's background work. */ + readonly clearThreadLiveness: (threadId: string) => void; + + /** + * Two-state vocabulary by design: any live agent work is "working"; + * "monitoring" only when watch loops are the ONLY live work. + */ + readonly getThreadBackgroundLiveness: (threadId: string) => ThreadBackgroundLiveness; + } +>()("t3/orchestration/ThreadBackgroundLiveness/ThreadBackgroundLivenessService") {} + +export function make(): ThreadBackgroundLivenessService["Service"] { + const stateByThreadId = new Map(); + + const stateFor = (threadId: string): ThreadLivenessState => { + const existing = stateByThreadId.get(threadId); + if (existing) { + return existing; + } + const created: ThreadLivenessState = { agents: new Set(), monitors: new Set() }; + stateByThreadId.set(threadId, created); + return created; + }; + + // Classification is per-transition, not sticky: a task first seen without + // a taskType may later reveal itself as a shell, become inert, or turn out + // to be agent-owned. Every path drops any prior entry for the taskId so a + // stale bucket assignment can't pin the thread's status (review finding). + const drop = (threadId: string, taskId: string) => { + const state = stateByThreadId.get(threadId); + if (!state) { + return; + } + state.agents.delete(taskId); + state.monitors.delete(taskId); + if (state.agents.size === 0 && state.monitors.size === 0) { + stateByThreadId.delete(threadId); + } + }; + + return { + recordTaskLiveness: (input) => { + const taskType = input.taskType; + if (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)) + ) { + drop(input.threadId, input.taskId); + return; + } + + // Idle counts as not-live: a resting (resumable) Codex child isn't + // doing anything, and an all-idle fleet must not pin Working. + const terminal = + input.kind === "completed" || + input.status === "idle" || + (input.status !== undefined && TERMINAL_STATUSES.has(input.status)); + if (terminal) { + drop(input.threadId, input.taskId); + return; + } + + drop(input.threadId, input.taskId); + const state = stateFor(input.threadId); + const bucket = + taskType !== undefined && MONITOR_TASK_TYPES.has(taskType) ? state.monitors : state.agents; + bucket.add(input.taskId); + }, + + clearThreadLiveness: (threadId) => { + stateByThreadId.delete(threadId); + }, + + getThreadBackgroundLiveness: (threadId) => { + const state = stateByThreadId.get(threadId); + if (!state) { + return null; + } + if (state.agents.size > 0) { + return "working"; + } + if (state.monitors.size > 0) { + return "monitoring"; + } + return null; + }, + }; +} + +export const layer = Layer.effect(ThreadBackgroundLivenessService, Effect.sync(make)); diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts index a2ed5875950..0bc624ec365 100644 --- a/apps/server/src/orchestration/runtimeLayer.ts +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -5,6 +5,7 @@ import { OrchestrationEventStoreLive } from "../persistence/Layers/Orchestration import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( OrchestrationEventStoreLive, @@ -19,7 +20,10 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( OrchestrationProjectionSnapshotQueryLive, OrchestrationEventInfrastructureLayerLive, OrchestrationProjectionPipelineLayerLive, -); + // Shared background-liveness registry: written by runtime ingestion, + // read by the snapshot query. provideMerge feeds the same instance to + // the snapshot query here and re-exports it for runtime ingestion. +).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer)); export const OrchestrationLayerLive = Layer.mergeAll( OrchestrationInfrastructureLayerLive, diff --git a/apps/server/src/orchestration/workflowScriptQuery.test.ts b/apps/server/src/orchestration/workflowScriptQuery.test.ts new file mode 100644 index 00000000000..47fe888de70 --- /dev/null +++ b/apps/server/src/orchestration/workflowScriptQuery.test.ts @@ -0,0 +1,75 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { afterAll, assert, describe } from "vite-plus/test"; +import { readWorkflowScript } from "./workflowScriptQuery.ts"; + +const root = NodePath.join(NodeOS.homedir(), ".claude", "projects", "__wf_script_test__"); +NodeFS.mkdirSync(root, { recursive: true }); +const scriptPath = NodePath.join(root, "run.js"); +NodeFS.writeFileSync(scriptPath, "export const meta = {};\n"); +const outside = NodePath.join(NodeOS.tmpdir(), "wf-outside.js"); +NodeFS.writeFileSync(outside, "evil\n"); +const link = NodePath.join(root, "sneaky.js"); +try { + NodeFS.symlinkSync(outside, link); +} catch (error) { + // Tolerate only "already exists" from a prior run — any other failure + // (EPERM etc.) must fail setup, or the escape test below would pass + // vacuously on "not-found" without testing containment. + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } +} +if (!NodeFS.lstatSync(link).isSymbolicLink()) { + throw new Error("test setup: sneaky.js must be a symlink"); +} + +afterAll(() => { + NodeFS.rmSync(root, { recursive: true, force: true }); + NodeFS.rmSync(outside, { force: true }); +}); + +describe("readWorkflowScript containment", () => { + effectIt.effect("serves a real script under the projects root", () => + Effect.gen(function* () { + const result = yield* readWorkflowScript({ scriptPath }); + assert.include(result.contents, "export const meta"); + assert.equal(result.truncated, false); + }), + ); + + effectIt.effect("rejects relative and non-js paths", () => + Effect.gen(function* () { + const relative = yield* Effect.exit(readWorkflowScript({ scriptPath: "run.js" })); + assert.equal(relative._tag, "Failure"); + const nonJs = yield* Effect.exit( + readWorkflowScript({ scriptPath: scriptPath.replace(".js", ".ts") }), + ); + assert.equal(nonJs._tag, "Failure"); + }), + ); + + effectIt.effect("rejects paths outside the root and symlink escapes", () => + Effect.gen(function* () { + const escaped = yield* Effect.exit(readWorkflowScript({ scriptPath: outside })); + assert.equal(escaped._tag, "Failure"); + // A symlink INSIDE the root pointing outside must fail specifically on + // realpath re-containment — a "not-found" would mean the link was + // never exercised and the assertion proves nothing. + const sneaky = yield* Effect.exit( + readWorkflowScript({ scriptPath: link }).pipe( + Effect.flip, + Effect.map((error) => error.reason), + ), + ); + assert.equal(sneaky._tag, "Success"); + if (sneaky._tag === "Success") { + assert.equal(sneaky.value, "outside-root"); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/workflowScriptQuery.ts b/apps/server/src/orchestration/workflowScriptQuery.ts new file mode 100644 index 00000000000..06bbd35ccf6 --- /dev/null +++ b/apps/server/src/orchestration/workflowScriptQuery.ts @@ -0,0 +1,124 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Read-only access to persisted workflow scripts for the Agents surface's + * "{} script" affordance. + * + * Containment rules (lifted from the reviewed #3650 inspection service): + * - the resolved realpath must live under ~/.claude/projects (where the + * Claude harness persists workflow scripts) — realpath re-containment + * defeats symlink escapes, including a symlinked leaf file; + * - only .js leaf files are served; + * - reads are size-capped rather than failed, with a truncation marker. + * + * The client-supplied path is a hint from the workflow's runHandles; it is + * never trusted beyond these checks. + */ +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +const SCRIPT_BYTE_CAP = 256 * 1024; + +function scriptsRoot(): string { + return NodePath.join(NodeOS.homedir(), ".claude", "projects"); +} + +export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: { + readonly scriptPath: string; +}) { + const requested = input.scriptPath; + + if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") { + return yield* Effect.fail( + new OrchestrationGetWorkflowScriptError({ reason: "invalid-path", scriptPath: requested }), + ); + } + + const root = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(scriptsRoot()), + catch: (cause) => + new OrchestrationGetWorkflowScriptError({ + reason: "root-unavailable", + scriptPath: requested, + cause, + }), + }); + + // Realpath the FILE itself (not just its directory): a symlink named + // like a script inside a contained directory must not escape. + const resolved = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(requested), + catch: (cause) => + new OrchestrationGetWorkflowScriptError({ + reason: "not-found", + scriptPath: requested, + cause, + }), + }); + + if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) { + return yield* Effect.fail( + new OrchestrationGetWorkflowScriptError({ reason: "outside-root", scriptPath: resolved }), + ); + } + if (NodePath.extname(resolved) !== ".js") { + return yield* Effect.fail( + new OrchestrationGetWorkflowScriptError({ reason: "not-js", scriptPath: resolved }), + ); + } + + // TOCTOU-safe read (review finding): open FIRST, then verify what was + // actually opened via the file descriptor. Re-checking the path after + // open would race against a swap; fstat on the handle cannot. The two + // containment checks fail with their own tagged reasons (not manufactured + // Errors folded into read-failed); "read-failed" is reserved for genuine + // platform failures with the real cause attached. + const read = yield* Effect.tryPromise({ + try: async () => { + const handle = await NodeFSP.open(resolved, "r"); + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + return { failure: "not-regular-file" as const }; + } + // The opened inode must be the same one realpath resolved to: a + // process swapping the path between realpath and open changes the + // inode, which this comparison catches. + const pathStat = await NodeFSP.lstat(resolved); + if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) { + return { failure: "changed-during-read" as const }; + } + const truncated = stat.size > SCRIPT_BYTE_CAP; + const buffer = Buffer.alloc(Math.min(stat.size, SCRIPT_BYTE_CAP)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + return { + contents: buffer.subarray(0, bytesRead).toString("utf8"), + truncated, + }; + } finally { + await handle.close(); + } + }, + catch: (cause) => + new OrchestrationGetWorkflowScriptError({ + reason: "read-failed", + scriptPath: resolved, + cause, + }), + }); + if ("failure" in read) { + return yield* new OrchestrationGetWorkflowScriptError({ + reason: read.failure, + scriptPath: resolved, + }); + } + + return { + scriptPath: resolved, + contents: read.contents, + truncated: read.truncated, + }; +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index f7e2358f6a9..e69d51c1747 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -55,6 +55,7 @@ class FakeClaudeQuery implements AsyncIterable { private failure: unknown | undefined; public readonly interruptCalls: Array = []; + public readonly stopTaskCalls: Array = []; public readonly setModelCalls: Array = []; public readonly setPermissionModeCalls: Array = []; public readonly setMaxThinkingTokensCalls: Array = []; @@ -98,6 +99,10 @@ class FakeClaudeQuery implements AsyncIterable { this.interruptCalls.push(undefined); }; + readonly stopTask = async (taskId: string): Promise => { + this.stopTaskCalls.push(taskId); + }; + readonly setModel = async (model?: string): Promise => { this.setModelCalls.push(model); }; @@ -1485,6 +1490,243 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("interruptTurn stops every live task before interrupting the turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + // Wait for the three task.* runtime events to prove the lifecycle + // handlers processed the emissions (no wall-clock sleeps under the + // test clock). + const taskEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type.startsWith("task.")), + Stream.take(3), + 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: "spawn agents", + attachments: [], + }); + + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-live", + description: "Agent A", + task_type: "local_agent", + uuid: "task-live-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-settled", + description: "Agent B", + task_type: "local_agent", + uuid: "task-settled-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: "task-settled", + status: "completed", + output_file: "/tmp/task-settled.jsonl", + summary: "done", + uuid: "task-settled-done-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + + yield* Fiber.join(taskEventsFiber); + + yield* adapter.interruptTurn(session.threadId); + + // Only the still-live task is stopped; interrupt always fires after. + assert.deepEqual(harness.query.stopTaskCalls, ["task-live"]); + assert.equal(harness.query.interruptCalls.length, 1); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("workflow member coalescing: identical snapshots suppress, changes emit", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + // Collect task.progress until member-0's tick-3 emission lands, then + // evaluate member emissions. + const progressFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "task.progress"), + Stream.takeUntil( + // Sentinel: member-0's tick-3 emission (tokens 20) — members are + // emitted after the coordinator row within a tick. + (event) => + (event.payload as { taskId?: string }).taskId === "wf-coalesce:wf:0" && + (event.payload as { typedUsage?: { totalTokens?: number } }).typedUsage?.totalTokens === + 20, + ), + 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 workflow", + attachments: [], + }); + + const memberSnapshot = (tokens: number) => [ + { type: "workflow_phase", index: 0, title: "Work" }, + { + type: "workflow_agent", + index: 0, + state: "running", + label: "member-0", + phaseIndex: 0, + tokens, + }, + { + type: "workflow_agent", + index: 1, + state: "running", + label: "member-1", + phaseIndex: 0, + tokens: 50, + }, + ]; + const tick = (usageTotal: number, snapshot: ReturnType) => + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "wf-coalesce", + description: "Coalescing workflow", + usage: { total_tokens: usageTotal, tool_uses: 1, duration_ms: 10 }, + workflow_progress: snapshot, + uuid: `wf-tick-${usageTotal}`, + session_id: "sdk-session", + } as unknown as SDKMessage); + + // Tick 1: both members are new -> 2 member events. + tick(100, memberSnapshot(10)); + // Tick 2: IDENTICAL member snapshot -> 0 member events (coordinator + // usage changed, but members did not). + tick(200, memberSnapshot(10)); + // Tick 3: member-0's tokens advanced -> exactly 1 member event. + tick(300, memberSnapshot(20)); + + const progressEvents = Array.from(yield* Fiber.join(progressFiber)); + const byMember = new Map(); + for (const event of progressEvents) { + const taskId = (event.payload as { taskId: string }).taskId; + if (!taskId.includes(":wf:")) continue; + byMember.set(taskId, (byMember.get(taskId) ?? 0) + 1); + } + // member-0: tick 1 + tick 3. member-1: tick 1 only (tick 2 identical, + // tick 3 unchanged). + assert.equal(byMember.get("wf-coalesce:wf:0"), 2); + assert.equal(byMember.get("wf-coalesce:wf:1"), 1); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("task.started carries model/effort; subagent snapshots refine the model", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const taskEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type.startsWith("task.")), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "max" }], + ), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "spawn an agent", + attachments: [], + }); + + // No explicit model/effort on the launch input: the task inherits the + // session's selection. + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-model", + description: "Agent M", + task_type: "local_agent", + tool_use_id: "toolu_agent_m", + uuid: "task-model-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + // The subagent's assistant snapshot carries the authoritative API + // model id, which refines the linkage on later rows. + harness.query.emit({ + type: "assistant", + parent_tool_use_id: "toolu_agent_m", + message: { + model: "claude-sonnet-5[1m]", + content: [], + }, + uuid: "subagent-snapshot-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-model", + description: "Agent M", + usage: { total_tokens: 100, tool_uses: 1, duration_ms: 10 }, + uuid: "task-model-progress-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + + const taskEvents = Array.from(yield* Fiber.join(taskEventsFiber)); + const started = taskEvents[0]; + assert.equal(started?.type, "task.started"); + if (started?.type === "task.started") { + assert.equal(started.payload.model, "claude-opus-4-6"); + assert.equal(started.payload.effort, "max"); + } + const progress = taskEvents[1]; + assert.equal(progress?.type, "task.progress"); + if (progress?.type === "task.progress") { + assert.equal(progress.payload.model, "claude-sonnet-5[1m]"); + assert.equal(progress.payload.effort, "max"); + } + }).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 185dea34efd..e0c950062a5 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -42,6 +42,10 @@ import { RuntimeItemId, RuntimeRequestId, RuntimeTaskId, + type RuntimeTaskStatus, + type RuntimeTaskUsage, + type TaskAgentLinkage, + type TaskRunHandles, ThreadId, TurnId, type UserInputQuestion, @@ -170,6 +174,9 @@ interface ToolInFlight { readonly input: Record; readonly partialInputJson: string; readonly lastEmittedInputFingerprint?: string; + /** Owning agent when this tool ran inside a subagent (see attribution note). */ + readonly agentId?: string; + readonly parentToolUseId?: string; } interface ClaudeTaskState { @@ -179,6 +186,28 @@ interface ClaudeTaskState { readonly blockedBy: Set; } +/** + * Agent identity captured from task_started and repeated on every subsequent + * task.* payload, so client folds can reconstruct an agent even when its + * start row aged out of activity retention. + */ +interface ClaudeTaskAgentState { + readonly taskId: string; + toolUseId: string | undefined; + description: string | undefined; + subagentType: string | undefined; + taskType: string | undefined; + workflowName: string | undefined; + skipTranscript: boolean; + runHandles: TaskRunHandles | undefined; + /** Set when this task was launched from inside a subagent. */ + owningAgentId: string | undefined; + /** Seeded from the launching tool's input; refined by the subagent's own + * assistant snapshots (authoritative API model). */ + model: string | undefined; + effort: string | undefined; +} + interface ClaudeSessionContext { session: ProviderSession; readonly promptQueue: Queue.Queue; @@ -187,6 +216,9 @@ interface ClaudeSessionContext { readonly startedAt: string; readonly basePermissionMode: PermissionMode | undefined; currentApiModelId: string | undefined; + /** Effective effort for the session's turns; subagents without an explicit + * effort override inherit this. */ + currentEffort: string | undefined; resumeSessionId: string | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -196,6 +228,17 @@ interface ClaudeSessionContext { }>; readonly inFlightTools: Map; readonly claudeTasks: Map; + readonly taskAgents: Map; + /** + * Last emitted workflow-member fingerprint per member slot. A coordinator + * task_progress repeats the FULL member array every tick; without a + * material-transition filter one provider tick fans out into up to 100 + * runtime events (event-log writes, queue pressure, client reducer work) + * even when nothing changed for most members. + */ + readonly workflowMemberFingerprints: Map; + /** Task ids that have started and not yet reached a terminal state. */ + readonly liveTaskIds: Set; turnState: ClaudeTurnState | undefined; lastKnownContextWindow: number | undefined; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; @@ -207,6 +250,8 @@ interface ClaudeSessionContext { interface ClaudeQueryRuntime extends AsyncIterable { readonly interrupt: () => Promise; + /** SDK Query.stopTask — present on real queries; optional for test doubles. */ + readonly stopTask?: (taskId: string) => Promise; readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; @@ -828,6 +873,225 @@ function planStepsFromClaudeTasks(tasks: Map): PlanStep }); } +/** Only http/https survive; anything else (javascript:, file:, …) is dropped. */ +function sanitizeSessionUrl(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!/^https?:\/\//i.test(trimmed)) { + return undefined; + } + return trimmed; +} + +function nonNegativeInt(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : undefined; +} + +function trimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * SDK task usage ({total_tokens, tool_uses, duration_ms}, sometimes with + * input/output/cache breakdowns) → the typed contract shape. Unknown or + * malformed input yields undefined rather than a partial guess. + */ +function normalizeTaskUsage(usage: unknown): RuntimeTaskUsage | undefined { + if (typeof usage !== "object" || usage === null) { + return undefined; + } + const record = usage as Record; + const totalTokens = nonNegativeInt(record.total_tokens); + if (totalTokens === undefined) { + return undefined; + } + const inputTokens = nonNegativeInt(record.input_tokens); + const cachedInputTokens = nonNegativeInt(record.cache_read_input_tokens); + const outputTokens = nonNegativeInt(record.output_tokens); + const toolUses = nonNegativeInt(record.tool_uses); + const durationMs = nonNegativeInt(record.duration_ms); + return { + totalTokens, + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(toolUses !== undefined ? { toolUses } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + }; +} + +/** SDK task_updated patch status → the shared wire vocabulary. */ +const CLAUDE_TASK_PATCH_STATUS: Record = { + pending: "pending", + running: "running", + completed: "completed", + failed: "failed", + killed: "cancelled", + paused: "idle", +}; + +/** + * Resolves a stream message's parent_tool_use_id to the owning agent's + * taskId. The Task tool's tool_use_id is remembered on task_started; any + * subagent-forwarded block carries that id as its parent. Returns undefined + * for parent-conversation traffic. + */ +function agentIdForParentToolUse( + agents: Map, + parentToolUseId: string | null | undefined, +): string | undefined { + if (parentToolUseId === null || parentToolUseId === undefined) { + return undefined; + } + for (const agent of agents.values()) { + if (agent.toolUseId === parentToolUseId) { + return agent.taskId; + } + } + return undefined; +} + +/** + * Linkage bundle repeated on every task.* payload for `taskId`. Reads the + * remembered identity (from task_started) so progress/terminal rows are + * self-describing even when the start row ages out of activity retention. + */ +function taskLinkageFor( + agents: Map, + taskId: string, +): TaskAgentLinkage { + const agent = agents.get(taskId); + if (!agent) { + return {}; + } + return { + ...(agent.taskType ? { taskType: agent.taskType } : {}), + ...(agent.owningAgentId ? { agentId: agent.owningAgentId } : {}), + ...(agent.description ? { title: agent.description } : {}), + ...(agent.subagentType ? { role: agent.subagentType } : {}), + ...(agent.model ? { model: agent.model } : {}), + ...(agent.effort ? { effort: agent.effort } : {}), + ...(agent.toolUseId ? { toolUseId: agent.toolUseId } : {}), + ...(agent.workflowName ? { workflowName: agent.workflowName } : {}), + ...(agent.runHandles ? { runHandles: agent.runHandles } : {}), + }; +} + +const WORKFLOW_PHASE_CAP = 64; +const WORKFLOW_AGENT_CAP = 100; + +interface ClaudeWorkflowAgentEntry { + readonly index: number; + readonly state: string; + readonly label: string | undefined; + readonly phaseIndex: number | undefined; + readonly phaseTitle: string | undefined; + readonly model: string | undefined; + readonly attempt: number | undefined; + readonly lastToolName: string | undefined; + readonly startedAt: string | undefined; + readonly error: string | undefined; + readonly tokens: number | undefined; + readonly toolCalls: number | undefined; +} + +interface ClaudeWorkflowProgress { + readonly phases: ReadonlyArray<{ index: number; title: string }>; + readonly agents: ReadonlyArray; +} + +/** + * Defensive parse of the SDK's undeclared-but-real workflow_progress array on + * task_progress messages (wire-confirmed; absent from sdk.d.ts). Unknown + * shapes are skipped per-entry; phases and agents dedupe by index before + * caps; a vanished field never throws. If the array disappears upstream the + * caller keeps the coordinator row and plain task lifecycle. + */ +function parseWorkflowProgress(value: unknown): ClaudeWorkflowProgress | undefined { + if (!Array.isArray(value) || value.length === 0) { + return undefined; + } + const phasesByIndex = new Map(); + const agentsByIndex = new Map(); + for (const entry of value) { + if (typeof entry !== "object" || entry === null) { + continue; + } + const record = entry as Record; + const entryType = trimmedString(record.type); + if (entryType === "workflow_phase") { + const index = nonNegativeInt(record.index); + const title = trimmedString(record.title); + if (index !== undefined && title && !phasesByIndex.has(index)) { + phasesByIndex.set(index, title); + } + continue; + } + if (entryType !== "workflow_agent") { + continue; + } + const index = nonNegativeInt(record.index); + const state = trimmedString(record.state); + if (index === undefined || !state || agentsByIndex.has(index)) { + continue; + } + agentsByIndex.set(index, { + index, + state, + label: trimmedString(record.label), + phaseIndex: nonNegativeInt(record.phaseIndex), + phaseTitle: trimmedString(record.phaseTitle), + model: trimmedString(record.model), + attempt: nonNegativeInt(record.attempt), + lastToolName: trimmedString(record.lastToolName), + startedAt: trimmedString(record.startedAt), + error: trimmedString(record.error), + tokens: nonNegativeInt(record.tokens), + toolCalls: nonNegativeInt(record.toolCalls), + }); + } + if (phasesByIndex.size === 0 && agentsByIndex.size === 0) { + return undefined; + } + const phases = Array.from(phasesByIndex.entries()) + .map(([index, title]) => ({ index, title })) + .toSorted((a, b) => a.index - b.index) + .slice(0, WORKFLOW_PHASE_CAP); + const agents = Array.from(agentsByIndex.values()) + .toSorted((a, b) => a.index - b.index) + .slice(0, WORKFLOW_AGENT_CAP); + return { phases, agents }; +} + +/** + * Workflow member states from workflow_progress → shared task status. + * Unknown states read running after startedAt, pending before it. + */ +function workflowAgentStatus(entry: ClaudeWorkflowAgentEntry): RuntimeTaskStatus { + switch (entry.state) { + case "queued": + case "pending": + return "pending"; + case "start": + case "running": + return entry.startedAt === undefined ? "pending" : "running"; + case "done": + return "completed"; + case "error": + return "failed"; + default: + return entry.startedAt === undefined ? "pending" : "running"; + } +} + function summarizeToolRequest(toolName: string, input: Record): string { const commandValue = input.command ?? input.cmd; const command = typeof commandValue === "string" ? commandValue : undefined; @@ -835,17 +1099,17 @@ function summarizeToolRequest(toolName: string, input: Record): return `${toolName}: ${command.trim().slice(0, 400)}`; } - // For agent/subagent tools, prefer human-readable description or prompt over raw JSON + // For agent/subagent tools, prefer the human-readable description or prompt + // over raw JSON. The structured subagent_type is carried separately on the + // task.* payloads (role) — the label is display-only. const itemType = classifyToolItemType(toolName); if (itemType === "collab_agent_tool_call") { const description = typeof input.description === "string" ? input.description.trim() : undefined; const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined; - const subagentType = - typeof input.subagent_type === "string" ? input.subagent_type.trim() : undefined; const label = description || (prompt ? prompt.slice(0, 200) : undefined); if (label) { - return subagentType ? `${subagentType}: ${label}` : label; + return label; } } @@ -2081,6 +2345,32 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const { event } = message; + // Subagent-owned stream traffic (parent_tool_use_id set) must not write + // into the parent transcript: with forwardSubagentText off the SDK still + // forwards subagent tool_use/tool_result blocks and their wrapping + // text/thinking deltas, and emitting them interleaved N subagents' + // narration into the chat (live-test finding). Their results reach the + // UI via the task.* lifecycle; their tool blocks are attributed and + // re-homed by the quiet-timeline filter. + const streamParentToolUseId = (message as { parent_tool_use_id?: string | null }) + .parent_tool_use_id; + if (streamParentToolUseId !== null && streamParentToolUseId !== undefined) { + // Drop only the subagent's narration (text/thinking); tool_use blocks + // and their input_json_delta frames must flow so attributed tool items + // keep their inputs (review finding: dropping deltas emptied inputs). + const dropStart = + event.type === "content_block_start" && + event.content_block.type !== "tool_use" && + event.content_block.type !== "server_tool_use" && + event.content_block.type !== "mcp_tool_use"; + const dropDelta = + event.type === "content_block_delta" && + (event.delta.type === "text_delta" || event.delta.type === "thinking_delta"); + if (dropStart || dropDelta) { + return; + } + } + if (event.type === "message_delta") { if (message.parent_tool_use_id !== null && message.parent_tool_use_id !== undefined) { return; @@ -2208,6 +2498,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: "inProgress", title: nextTool.title, ...(nextTool.detail ? { detail: nextTool.detail } : {}), + ...(nextTool.agentId ? { agentId: nextTool.agentId } : {}), + ...(nextTool.parentToolUseId ? { parentToolUseId: nextTool.parentToolUseId } : {}), data: { toolName: nextTool.toolName, input: nextTool.input, @@ -2277,6 +2569,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const inputFingerprint = Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; + // Attribute tools that ran inside a subagent to their owning agent so + // clients can re-home them out of the main timeline (quiet-timeline + // guarantee): the SDK forwards subagent tool_use blocks tagged with the + // spawning Task tool's id as parent_tool_use_id. + const parentToolUseId = + (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? undefined; + const owningAgentId = agentIdForParentToolUse(context.taskAgents, parentToolUseId); + const tool: ToolInFlight = { itemId, itemType, @@ -2286,6 +2586,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: toolInput, partialInputJson: "", ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}), + ...(owningAgentId ? { agentId: owningAgentId } : {}), + ...(parentToolUseId ? { parentToolUseId } : {}), }; context.inFlightTools.set(index, tool); @@ -2303,6 +2605,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: "inProgress", title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), + ...(tool.agentId ? { agentId: tool.agentId } : {}), + ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), data: { toolName: tool.toolName, input: toolInput, @@ -2381,6 +2685,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: toolResult.isError ? "failed" : "inProgress", title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), + ...(tool.agentId ? { agentId: tool.agentId } : {}), + ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), data: toolData, }, providerRefs: nativeProviderRefs(context, { @@ -2433,6 +2739,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: itemStatus, title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), + ...(tool.agentId ? { agentId: tool.agentId } : {}), + ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), data: toolData, }, providerRefs: nativeProviderRefs(context, { @@ -2445,6 +2753,43 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); + // The Workflow tool's result carries the run handles (runId, scriptPath, + // transcriptDir, sessionUrl). Attach them to the workflow's task agent so + // the next task.* payload advertises them to clients. + if (!toolResult.isError && tool.toolName.toLowerCase() === "workflow" && toolUseResult) { + const workflowTaskId = trimmedString(toolUseResult.taskId); + if (workflowTaskId) { + const runHandles: TaskRunHandles = { + ...(trimmedString(toolUseResult.runId) + ? { runId: trimmedString(toolUseResult.runId) } + : {}), + ...(trimmedString(toolUseResult.scriptPath) + ? { scriptPath: trimmedString(toolUseResult.scriptPath) } + : {}), + ...(trimmedString(toolUseResult.transcriptDir) + ? { transcriptDir: trimmedString(toolUseResult.transcriptDir) } + : {}), + ...(sanitizeSessionUrl(toolUseResult.sessionUrl) + ? { sessionUrl: sanitizeSessionUrl(toolUseResult.sessionUrl) } + : {}), + }; + const existing = context.taskAgents.get(workflowTaskId); + context.taskAgents.set(workflowTaskId, { + taskId: workflowTaskId, + toolUseId: existing?.toolUseId ?? tool.itemId, + description: existing?.description, + subagentType: existing?.subagentType, + taskType: existing?.taskType ?? "local_workflow", + workflowName: existing?.workflowName, + skipTranscript: existing?.skipTranscript ?? false, + runHandles, + owningAgentId: existing?.owningAgentId, + model: existing?.model, + effort: existing?.effort, + }); + } + } + if ( !toolResult.isError && applyClaudeTaskToolResult(context.claudeTasks, tool, toolUseResult) @@ -2468,6 +2813,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } + // Subagent-owned assistant snapshots (parent_tool_use_id set) are the + // subagent's own conversation, not the parent's. Emitting them created + // interleaved "Agent N done"-adjacent leak messages and spawned synthetic + // turns per subagent completion (which also reset the Working timer). + const assistantParentToolUseId = (message as { parent_tool_use_id?: string | null }) + .parent_tool_use_id; + if (assistantParentToolUseId !== null && assistantParentToolUseId !== undefined) { + // The snapshot's message.model is the authoritative API model the + // subagent actually ran on — refine the seeded launch-time value. + const owningTaskId = agentIdForParentToolUse(context.taskAgents, assistantParentToolUseId); + const snapshotModel = trimmedString(message.message.model); + const owningAgent = owningTaskId ? context.taskAgents.get(owningTaskId) : undefined; + if (owningAgent && snapshotModel) { + owningAgent.model = snapshotModel; + } + context.lastAssistantUuid = message.uuid; + yield* updateResumeCursor(context); + return; + } + // Auto-start a synthetic turn for assistant messages that arrive without // an active turn (e.g., background agent/subagent responses between user prompts). if (!context.turnState) { @@ -2566,6 +2931,82 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* completeTurn(context, status, errorMessage, message); }); + /** + * Synthesizes per-member task.progress rows from the coordinator's + * workflow_progress array. Member identity is the stable slot + * `:wf:` (never the per-attempt agent id, which + * changes on retry and would split one member into duplicate rows). + * timelineBypass keeps these out of the parent chat; the Agents surface and + * workflow card consume them. + */ + const emitWorkflowMemberProgress = Effect.fn("emitWorkflowMemberProgress")(function* ( + context: ClaudeSessionContext, + base: Omit, + message: Extract, + ) { + const progress = parseWorkflowProgress( + (message as unknown as Record).workflow_progress, + ); + if (!progress) { + return; + } + const coordinatorId = message.task_id; + for (const entry of progress.agents) { + const memberTaskId = `${coordinatorId}:wf:${entry.index}`; + const status = workflowAgentStatus(entry); + // Material-transition filter: the wire repeats every member each tick. + // Emit only when something the client renders actually changed, so a + // 100-agent fleet costs ~1 event per changed member instead of 100 + // per tick (review finding: unbounded event amplification). + const fingerprint = [ + status, + entry.label ?? "", + entry.model ?? "", + entry.lastToolName ?? "", + entry.error ?? "", + entry.tokens ?? "", + entry.toolCalls ?? "", + entry.phaseIndex ?? "", + entry.phaseTitle ?? "", + entry.attempt ?? "", + ].join("\u001f"); + if (context.workflowMemberFingerprints.get(memberTaskId) === fingerprint) { + continue; + } + context.workflowMemberFingerprints.set(memberTaskId, fingerprint); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + eventId: stamp.eventId, + createdAt: stamp.createdAt, + type: "task.progress", + payload: { + taskId: RuntimeTaskId.make(memberTaskId), + description: entry.label ?? `agent ${entry.index}`, + status, + ...(entry.error ? { error: entry.error } : {}), + ...(entry.label ? { title: entry.label } : {}), + ...(entry.model ? { model: entry.model } : {}), + ...(entry.lastToolName ? { lastToolName: entry.lastToolName } : {}), + ...(entry.tokens !== undefined + ? { + typedUsage: { + totalTokens: entry.tokens, + ...(entry.toolCalls !== undefined ? { toolUses: entry.toolCalls } : {}), + }, + } + : {}), + parentAgentId: coordinatorId, + agentIndex: entry.index, + ...(entry.phaseIndex !== undefined ? { phaseIndex: entry.phaseIndex } : {}), + ...(entry.phaseTitle ? { phaseTitle: entry.phaseTitle } : {}), + ...(entry.attempt !== undefined ? { attempt: entry.attempt } : {}), + timelineBypass: true, + }, + }); + } + }); + const handleSystemMessage = Effect.fn("handleSystemMessage")(function* ( context: ClaudeSessionContext, message: SDKMessage, @@ -2681,7 +3122,46 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); return; - case "task_started": + case "task_started": { + // A task launched by a tool that itself ran inside a subagent (the + // in-flight tool carries agentId from parent_tool_use_id) is + // agent-internal: a subagent's background shell, not parent work. + const launchingTool = message.tool_use_id + ? Array.from(context.inFlightTools.values()).find( + (tool) => tool.itemId === message.tool_use_id, + ) + : 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 launchInput = launchingTool?.input; + const 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); + // 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, + taskType: message.task_type, + workflowName: message.workflow_name, + skipTranscript: message.skip_transcript === true, + runHandles: context.taskAgents.get(message.task_id)?.runHandles, + owningAgentId, + model, + effort, + }); + context.liveTaskIds.add(message.task_id); yield* offerRuntimeEvent({ ...base, type: "task.started", @@ -2689,10 +3169,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( taskId: RuntimeTaskId.make(message.task_id), description: message.description, ...(message.task_type ? { taskType: message.task_type } : {}), + ...(owningAgentId ? { agentId: owningAgentId } : {}), + ...(message.description ? { title: message.description } : {}), + ...(message.subagent_type ? { role: message.subagent_type } : {}), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + ...(message.tool_use_id ? { toolUseId: message.tool_use_id } : {}), + ...(message.workflow_name ? { workflowName: message.workflow_name } : {}), }, }); return; - case "task_progress": + } + case "task_progress": { yield* emitThreadTokenUsage( context, normalizeClaudeTaskProgressTokenUsage(message.usage, context), @@ -2701,6 +3189,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( rawPayload: message, }, ); + const linkage = taskLinkageFor(context.taskAgents, message.task_id); + const typedUsage = normalizeTaskUsage(message.usage); + // Phases ride on the coordinator's ONE progress row per tick. A + // separate phases-only row shared the stable ingestion activity id + // with this full row, and the thinner upsert overwrote usage and + // progress text (review finding). + const workflowPhases = parseWorkflowProgress( + (message as unknown as Record).workflow_progress, + )?.phases; yield* offerRuntimeEvent({ ...base, type: "task.progress", @@ -2709,16 +3206,48 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( description: message.description, ...(message.summary ? { summary: message.summary } : {}), ...(message.usage ? { usage: message.usage } : {}), + ...(typedUsage ? { typedUsage } : {}), ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), + ...(workflowPhases && workflowPhases.length > 0 ? { phases: workflowPhases } : {}), + ...linkage, + ...(message.subagent_type ? { role: message.subagent_type } : {}), }, }); + yield* emitWorkflowMemberProgress(context, base, message); return; - // Task state patch (status/backgrounded/end_time). No runtime mapping - // yet — the terminal task_notification reports the outcome — but it - // must not surface as an unknown-subtype warning row. - case "task_updated": + } + case "task_updated": { + // Status patch (killed/paused/backgrounded/end_time/error) — main + // previously dropped this on the floor, losing all transitions. + const patch = message.patch; + const status = + patch.status !== undefined ? CLAUDE_TASK_PATCH_STATUS[patch.status] : undefined; + if (status === "completed" || status === "failed" || status === "cancelled") { + context.liveTaskIds.delete(message.task_id); + } + const endedAt = + typeof patch.end_time === "number" && Number.isFinite(patch.end_time) + ? DateTime.formatIso(DateTime.makeUnsafe(patch.end_time)) + : undefined; + yield* offerRuntimeEvent({ + ...base, + type: "task.updated", + payload: { + taskId: RuntimeTaskId.make(message.task_id), + ...(status ? { status } : {}), + ...(patch.description ? { description: patch.description } : {}), + ...(patch.error ? { error: patch.error } : {}), + ...(endedAt ? { endedAt } : {}), + ...(patch.is_backgrounded !== undefined + ? { isBackgrounded: patch.is_backgrounded } + : {}), + ...taskLinkageFor(context.taskAgents, message.task_id), + }, + }); return; - case "task_notification": + } + case "task_notification": { + context.liveTaskIds.delete(message.task_id); yield* emitThreadTokenUsage( context, normalizeClaudeTaskProgressTokenUsage(message.usage, context), @@ -2727,6 +3256,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( rawPayload: message, }, ); + const typedUsage = normalizeTaskUsage(message.usage); yield* offerRuntimeEvent({ ...base, type: "task.completed", @@ -2735,9 +3265,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: message.status, ...(message.summary ? { summary: message.summary } : {}), ...(message.usage ? { usage: message.usage } : {}), + ...(typedUsage ? { typedUsage } : {}), + ...(message.output_file ? { outputFile: message.output_file } : {}), + ...taskLinkageFor(context.taskAgents, message.task_id), }, }); return; + } case "files_persisted": yield* offerRuntimeEvent({ ...base, @@ -2881,7 +3415,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( toolUseId: message.tool_use_id, toolName: message.tool_name, elapsedSeconds: message.elapsed_time_seconds, - ...(message.task_id ? { summary: `task:${message.task_id}` } : {}), + ...(message.task_id ? { taskId: RuntimeTaskId.make(message.task_id) } : {}), + ...(message.parent_tool_use_id !== null + ? { parentToolUseId: message.parent_tool_use_id } + : {}), }, }); return; @@ -3228,6 +3765,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const pendingUserInputs = new Map(); const inFlightTools = new Map(); const claudeTasks = new Map(); + const taskAgents = new Map(); + const workflowMemberFingerprints = new Map(); + const liveTaskIds = new Set(); const contextRef = yield* Ref.make(undefined); @@ -3664,12 +4204,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( startedAt, basePermissionMode: permissionMode, currentApiModelId: apiModelId, + currentEffort: effectiveEffort ?? undefined, resumeSessionId: sessionId, pendingApprovals, pendingUserInputs, turns: [], inFlightTools, claudeTasks, + taskAgents, + workflowMemberFingerprints, + liveTaskIds, turnState: undefined, lastKnownContextWindow: initialContextWindow, lastKnownTokenUsage: undefined, @@ -3786,6 +4330,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...context.session, model: modelSelection.model, }; + const turnCaps = getClaudeModelCapabilities(modelSelection.model); + const turnEffort = resolveClaudeEffort( + turnCaps, + getModelSelectionStringOptionValue(modelSelection, "effort"), + ); + context.currentEffort = + getEffectiveClaudeAgentEffort(turnEffort ?? null, modelSelection.model) ?? undefined; } // Apply interaction mode by switching the SDK's permission mode. @@ -3861,6 +4412,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, _turnId) { const context = yield* requireSession(threadId); + // Stop-everything semantics: users reach for Stop precisely when a + // fleet ran away. interrupt() alone only ends the parent turn — + // background subagents/shells keep running and keep burning tokens. + // Stop every live task first (best-effort per task: one refusal must + // not strand the rest or block the turn interrupt), then interrupt. + if (context.query.stopTask && context.liveTaskIds.size > 0) { + const liveIds = Array.from(context.liveTaskIds); + // Bounded: a wedged child's stopTask promise may never settle + // (Effect.ignore handles rejection, not non-resolution), and the + // parent interrupt below MUST still run — Stop matters most during + // runaway fleets (review finding). Per-task timeout keeps one hung + // child from consuming the whole budget. + yield* Effect.forEach( + liveIds, + (taskId) => + Effect.tryPromise({ + // Invoke through the query object: SDK methods rely on `this`. + try: () => context.query.stopTask!(taskId), + catch: () => undefined, + }).pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), + { concurrency: 8, discard: true }, + ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); + } yield* Effect.tryPromise({ try: () => context.query.interrupt(), catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index de4edc31cdf..4bcbf6e6dcc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -20,6 +20,8 @@ import { type ProviderUserInputAnswers, RuntimeItemId, RuntimeRequestId, + RuntimeTaskId, + type RuntimeTaskUsage, ProviderApprovalDecision, ThreadId, ProviderSendTurnInput, @@ -497,10 +499,276 @@ function mapItemLifecycle( }; } +/** + * Maps the session runtime's synthetic `collabAgent/*` events (native + * multi-agent v2 child-thread signals) into the shared task.* lifecycle. + * Agent identity = child thread id; nickname is the display title, role is + * agentRole (fallback: last agentPath segment, then "general-purpose"). + * A completed child turn is idle (resumable), not terminal. timelineBypass + * keeps these rows out of the parent chat. + */ +function mapCollabAgentEvent( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): ReadonlyArray { + const payload = + typeof event.payload === "object" && event.payload !== null + ? (event.payload as Record) + : undefined; + const agentThreadId = typeof payload?.agentThreadId === "string" ? payload.agentThreadId : ""; + if (!payload || agentThreadId.length === 0) { + return []; + } + const base = runtimeEventBase(event, canonicalThreadId); + const taskId = RuntimeTaskId.make(agentThreadId); + const agentPath = typeof payload.agentPath === "string" ? payload.agentPath : undefined; + const pathLeaf = agentPath?.split("/").findLast((segment) => segment.length > 0); + const nickname = typeof payload.nickname === "string" ? payload.nickname : undefined; + const role = + (typeof payload.role === "string" ? payload.role : undefined) ?? pathLeaf ?? "general-purpose"; + // A bare thread id is not a name. Omitting the title lets the client fold + // keep the real one from task.started instead of clobbering it (probe + // finding: progress rows renamed math_one to its UUID). + const knownName = nickname ?? pathLeaf; + const title = knownName ?? agentThreadId; + // Identity repeated on every status patch so rows are self-describing when + // the start row ages out of activity retention (review finding: a + // reconstructed agent had a UUID name and no role/path). + const statusLinkage = { + role, + ...(knownName ? { title: knownName } : {}), + ...(agentPath ? { agentPath } : {}), + timelineBypass: true, + } as const; + + switch (event.method) { + case "collabAgent/started": + return [ + { + ...base, + type: "task.started", + payload: { + taskId, + description: title, + title, + role, + ...(agentPath ? { agentPath } : {}), + ...(typeof payload.parentThreadId === "string" + ? { parentAgentId: payload.parentThreadId } + : {}), + timelineBypass: true, + }, + }, + ]; + case "collabAgent/activity": { + const activityKind = typeof payload.activityKind === "string" ? payload.activityKind : ""; + if (activityKind === "interrupted") { + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "interrupted", ...statusLinkage }, + }, + ]; + } + if (activityKind === "started") { + // Wire-probe finding: children often register via subAgentActivity + // alone (no thread/started with a spawn source), so this is the one + // shot at a task.started with a real name — agentPath leaf beats a + // bare thread-id title. + return [ + { + ...base, + type: "task.started", + payload: { + taskId, + description: title, + title, + role, + ...(agentPath ? { agentPath } : {}), + timelineBypass: true, + }, + }, + ]; + } + // interacted → the child is (again) actively driven. + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "running", ...statusLinkage }, + }, + ]; + } + case "collabAgent/turnStarted": + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "running", ...statusLinkage }, + }, + ]; + case "collabAgent/turnCompleted": { + // Idle, not terminal: the identity is resumable via sendInput/resume. + const turn = + typeof payload.turn === "object" && payload.turn !== null + ? (payload.turn as Record) + : undefined; + const turnStatus = typeof turn?.status === "string" ? turn.status : undefined; + const status = + turnStatus === "failed" + ? ("failed" as const) + : turnStatus === "interrupted" + ? ("interrupted" as const) + : ("idle" as const); + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status, ...statusLinkage }, + }, + ]; + } + case "collabAgent/statusChanged": { + const status = + typeof payload.status === "object" && payload.status !== null + ? (payload.status as Record) + : undefined; + const statusType = typeof status?.type === "string" ? status.type : undefined; + if (statusType === "systemError") { + // Silently dropping this once left children stuck running forever. + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "failed", ...statusLinkage }, + }, + ]; + } + if (statusType === "active") { + const flags = Array.isArray(status?.activeFlags) ? status.activeFlags : []; + const waiting = flags.some( + (flag) => flag === "waitingOnApproval" || flag === "waitingOnUserInput", + ); + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: waiting ? "waiting" : "running", ...statusLinkage }, + }, + ]; + } + if (statusType === "idle") { + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "idle", ...statusLinkage }, + }, + ]; + } + return []; + } + case "collabAgent/tokenUsage": { + // Cumulative per child thread: always the `total` breakdown, never + // `last` (which shrinks on follow-ups). Client folds max-merge. + const tokenUsage = + typeof payload.tokenUsage === "object" && payload.tokenUsage !== null + ? (payload.tokenUsage as Record) + : undefined; + const total = + typeof tokenUsage?.total === "object" && tokenUsage.total !== null + ? (tokenUsage.total as Record) + : undefined; + const count = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; + // Same validation as every other field: RuntimeTaskUsage.totalTokens + // is NonNegativeInt, so NaN/Infinity/negative wire values must miss. + const totalTokens = count(total?.totalTokens); + if (totalTokens === undefined) { + return []; + } + const typedUsage: RuntimeTaskUsage = { + totalTokens, + ...(count(total?.inputTokens) !== undefined + ? { inputTokens: count(total?.inputTokens) } + : {}), + ...(count(total?.cachedInputTokens) !== undefined + ? { cachedInputTokens: count(total?.cachedInputTokens) } + : {}), + ...(count(total?.outputTokens) !== undefined + ? { outputTokens: count(total?.outputTokens) } + : {}), + ...(count(total?.reasoningOutputTokens) !== undefined + ? { reasoningOutputTokens: count(total?.reasoningOutputTokens) } + : {}), + }; + return [ + { + ...base, + type: "task.progress", + payload: { + taskId, + description: title, + ...(knownName ? { title: knownName } : {}), + typedUsage, + timelineBypass: true, + }, + }, + ]; + } + case "collabAgent/item": { + const item = + typeof payload.item === "object" && payload.item !== null + ? (payload.item as Record) + : undefined; + const itemTypeRaw = typeof item?.type === "string" ? item.type : undefined; + if (!itemTypeRaw) { + return []; + } + // A loose summary from the raw item: the child stream is untyped at + // this boundary (synthetic event payload), so read best-effort fields + // rather than force a schema decode. + const looseSummary = + (typeof item?.command === "string" ? item.command : undefined) ?? + (typeof item?.title === "string" ? item.title : undefined) ?? + (typeof item?.query === "string" ? item.query : undefined); + const canonical = toCanonicalItemType(itemTypeRaw); + const summary = looseSummary ?? canonical.replaceAll("_", " "); + return [ + { + ...base, + type: "task.progress", + payload: { + taskId, + description: title, + ...(knownName ? { title: knownName } : {}), + summary, + timelineBypass: true, + }, + }, + ]; + } + case "collabAgent/closed": + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "interrupted", ...statusLinkage }, + }, + ]; + default: + return []; + } +} + function mapToRuntimeEvents( event: ProviderEvent, canonicalThreadId: ThreadId, ): ReadonlyArray { + if (event.kind === "notification" && event.method.startsWith("collabAgent/")) { + return mapCollabAgentEvent(event, canonicalThreadId); + } if (event.kind === "error") { if (!event.message) { return []; diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts new file mode 100644 index 00000000000..3a02c45b23f --- /dev/null +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -0,0 +1,248 @@ +/** + * Runtime-level collab regression: boots the REAL CodexSessionRuntime against + * a scripted mock app-server peer that replays the captured multi-agent wire + * sequence (codexMultiAgentWire.json) plus the shapes the capture alone can't + * script (receiver-turn bookkeeping via collabAgentToolCall, child terminal + * lifecycle, approval pass-through). This is the layer the pure routing-table + * test can't reach: ordering between the legacy receiver-turn suppressor and + * v2 interception, registration state, and synthetic event emission. + */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Stream from "effect/Stream"; +import { assert, describe } from "vite-plus/test"; + +import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; +import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; + +const ROOT = wireFixture.rootThreadId; +const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; + +/** + * The captured sequence, extended with the shapes the live capture didn't + * include: a collabAgentToolCall with receiverThreadIds (feeds the legacy + * receiver-turn map, so ordering vs. v2 interception is exercised), child + * terminal lifecycle, and a serverRequest/resolved addressed to a child + * (must pass through to the parent path, not vanish). + */ +function buildScript() { + const captured = wireFixture.notifications; + const extras = [ + { + method: "item/completed", + params: { + threadId: ROOT, + item: { + type: "collabAgentToolCall", + id: "call_fixture_wait", + tool: "wait", + status: "completed", + senderThreadId: ROOT, + receiverThreadIds: [CHILD_A, CHILD_B], + }, + }, + }, + // Child terminal lifecycle AFTER the receiver map knows the children — + // pre-fix, the legacy suppressor dropped these before interception saw + // them, so no synthetic agent events were emitted. + { + method: "turn/completed", + params: { + threadId: CHILD_A, + turn: { id: `${CHILD_A}-turn-1`, status: "completed", items: [] }, + }, + }, + { method: "thread/closed", params: { threadId: CHILD_B } }, + // Parent-owned traffic addressed to a child conversation: must reach the + // parent path (approval correlation cleanup), not be swallowed. + { method: "serverRequest/resolved", params: { threadId: CHILD_A, requestId: "req-1" } }, + ]; + return { + rootThreadId: ROOT, + notifications: [...captured.filter((entry) => entry.method !== "turn/completed"), ...extras], + }; +} + +const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); +const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh"); + +describe("CodexSessionRuntime collab integration", () => { + it.effect("replays the captured fan-out into synthetic agent events without child leaks", () => + Effect.gen(function* () { + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(buildScript()), "utf8"); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(scriptPath, { force: true })), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-collab-integration"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + const eventsFiber = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.method === "turn/completed"), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "fan out" }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const methods = events.map((event) => event.method); + + // Children registered from subAgentActivity become synthetic agent + // lifecycle — including terminal rows that arrive AFTER the receiver + // map knows them (the ordering this test exists to pin). + assert.include(methods, "collabAgent/activity"); + assert.include(methods, "collabAgent/turnCompleted"); + assert.include(methods, "collabAgent/closed"); + + const childTurnCompleted = events.find( + (event) => + event.method === "collabAgent/turnCompleted" && + (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, + ); + assert.isDefined(childTurnCompleted, "child A's turn completion becomes an agent event"); + + const childClosed = events.find( + (event) => + event.method === "collabAgent/closed" && + (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B, + ); + assert.isDefined(childClosed, "child B's close becomes an agent event"); + + // Parent-owned resolution passes through — not swallowed, not + // re-labelled as an agent event. + assert.include(methods, "serverRequest/resolved"); + + // The root's own subAgentActivity about "/root" must NOT register the + // root as a child: the parent turn completion still flows. + assert.include(methods, "turn/completed"); + + // No raw child conversation methods leak onto the parent stream. + const leaked = events.filter((event) => { + const payload = event.payload as { threadId?: string } | undefined; + const addressedToChild = payload?.threadId === CHILD_A || payload?.threadId === CHILD_B; + return addressedToChild && (event.method?.startsWith("thread/") ?? false); + }); + assert.deepEqual( + leaked.map((event) => event.method), + [], + "child thread/* lifecycle must not appear as parent events", + ); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + // it.live: the runtime talks to a real child process; under it.effect's + // TestClock the internal timers freeze and the join never completes. + it.live("Stop interrupts every live child regardless of registration timing", () => + Effect.gen(function* () { + // Ordering + liveness torture for stop-everything: child A's + // turn/started arrives BEFORE anything registers it (foreign + // suppression path must record the live turn); child B's arrives after + // registration; child A's interrupt HANGS (RPC never settles — worse + // than rejecting) and the bounded deadline must still deliver B's and + // the parent's interrupts. The turn stays open so children are live + // when Stop fires. + // Build from REAL captured rows (hand-written shapes fail notification + // schema validation and are silently dropped): reorder so child A's + // turn/started precedes its registration, and drop terminal rows so + // children stay live when Stop fires. + const byIndex = wireFixture.notifications; + const isTurnStarted = (entry: (typeof byIndex)[number], child: string) => + entry.method === "turn/started" && + (entry.params as { threadId?: string }).threadId === child; + const isRegistration = (entry: (typeof byIndex)[number], child: string) => { + const item = (entry.params as { item?: { type?: string; agentThreadId?: string } }).item; + return item?.type === "subAgentActivity" && item.agentThreadId === child; + }; + const turnStartedA = byIndex.find((entry) => isTurnStarted(entry, CHILD_A)); + const turnStartedB = byIndex.find((entry) => isTurnStarted(entry, CHILD_B)); + const registrationA = byIndex.find((entry) => isRegistration(entry, CHILD_A)); + const registrationB = byIndex.find((entry) => isRegistration(entry, CHILD_B)); + assert.isDefined(turnStartedA); + assert.isDefined(turnStartedB); + assert.isDefined(registrationA); + assert.isDefined(registrationB); + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + hangInterruptFor: CHILD_A, + notifications: [turnStartedA, registrationA, registrationB, turnStartedB], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + const interruptsPath = `${scriptPath}.interrupts`; + NodeFS.rmSync(interruptsPath, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(interruptsPath, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-collab-stop"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + // Wait for both children's turnStarted signals to be processed before + // stopping (B via the registered-child path; A only produces live-turn + // bookkeeping, so key on B's synthetic event). + const childBStartedFiber = yield* runtime.events.pipe( + Stream.filter( + (event) => + event.method === "collabAgent/turnStarted" && + (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B, + ), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "fan out and hang" }); + const childBStarted = yield* Fiber.join(childBStartedFiber).pipe( + Effect.timeoutOption("15 seconds"), + ); + assert.isTrue(childBStarted._tag === "Some", "child B turnStarted never arrived"); + + // Stop everything. A's interrupt hangs forever — the bounded child + // deadline must expire and the parent interrupt must still be sent. + yield* runtime.interruptTurn(); + + const parseInterruptLine = (line: string) => JSON.parse(line) as { threadId?: string }; + const interrupted = NodeFS.readFileSync(interruptsPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map(parseInterruptLine); + const interruptedThreads = new Set(interrupted.map((entry) => entry.threadId)); + assert.isTrue( + interruptedThreads.has(CHILD_A), + "pre-registration child A must still receive the interrupt RPC", + ); + assert.isTrue(interruptedThreads.has(CHILD_B), "registered child B must be interrupted"); + assert.isTrue(interruptedThreads.has(ROOT), "parent turn must be interrupted last"); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/Layers/CodexCollabWire.test.ts b/apps/server/src/provider/Layers/CodexCollabWire.test.ts new file mode 100644 index 00000000000..50e5e819d1f --- /dev/null +++ b/apps/server/src/provider/Layers/CodexCollabWire.test.ts @@ -0,0 +1,180 @@ +/** + * Codex multi-agent wire fixtures. + * + * The child-interception path is the highest-blast-radius code in the + * subagent stack: it decides, per notification, whether traffic reaches the + * parent timeline at all. Three shipped bugs came from that decision being + * implicit (root thread registered as its own child; `error` and + * `serverRequest/resolved` swallowed by a catch-all). These tests pin the + * decision against a REAL capture rather than hand-written shapes. + * + * Fixture provenance: codex-cli 0.145.0 driven directly over stdio with + * gpt-5.6-luna at low effort, prompting a two-child fan-out (alpha, beta). + * See codexMultiAgentWire.json. + */ +import { assert, describe, it } from "vite-plus/test"; + +import fixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; +import { routeCodexChildNotification } from "./CodexSessionRuntime.ts"; + +interface WireNotification { + readonly method: string; + readonly params: Record; +} + +const notifications = fixture.notifications as ReadonlyArray; +const rootThreadId = fixture.rootThreadId; +const childThreadIds = new Set(fixture.childThreadIds); + +/** Mirrors readNotificationThreadId's addressing for the captured methods. */ +function notificationThreadId(entry: WireNotification): string | undefined { + const params = entry.params; + const thread = params.thread; + if ( + typeof thread === "object" && + thread !== null && + typeof (thread as { id?: unknown }).id === "string" + ) { + return (thread as { id: string }).id; + } + return typeof params.threadId === "string" ? params.threadId : undefined; +} + +function subAgentActivityItems(): ReadonlyArray> { + return notifications.flatMap((entry) => { + const item = entry.params.item; + if (typeof item !== "object" || item === null) return []; + const record = item as Record; + return record.type === "subAgentActivity" ? [record] : []; + }); +} + +describe("codex multi-agent wire capture", () => { + it("captures a real two-child fan-out", () => { + assert.equal(fixture.capturedWith.model, "gpt-5.6-luna"); + assert.equal(childThreadIds.size, 2); + const paths = subAgentActivityItems().map((item) => item.agentPath); + assert.include(paths, "/root/alpha"); + assert.include(paths, "/root/beta"); + }); + + it("emits child traffic BEFORE the item that registers the child", () => { + // Ordering hazard: the child's own thread/status/changed arrives before + // the parent-side subAgentActivity naming it. Registration must tolerate + // child-first arrival, so unregistered child traffic passes through + // rather than being eaten (no regression vs. pre-feature behavior). + const firstChildTraffic = notifications.findIndex((entry) => { + const threadId = notificationThreadId(entry); + return threadId !== undefined && childThreadIds.has(threadId); + }); + const firstRegistration = notifications.findIndex((entry) => { + const item = entry.params.item; + if (typeof item !== "object" || item === null) return false; + const record = item as Record; + return record.type === "subAgentActivity" && record.kind === "started"; + }); + assert.isAtLeast(firstChildTraffic, 0); + assert.isAtLeast(firstRegistration, 0); + assert.isBelow( + firstChildTraffic, + firstRegistration, + "capture should exercise child-first ordering", + ); + }); + + it("contains a /root self-activity emitted from a CHILD thread", () => { + // The bug this guards: the wire reports subAgentActivity about the ROOT + // (agentPath "/root"). Registering it made the runtime intercept the + // parent's own final message and turn/completed, so the thread hung + // "working" forever. The root guard must key on agentPath/thread id, + // never on which thread the notification arrived from. + const rootSelfActivity = subAgentActivityItems().find((item) => item.agentPath === "/root"); + assert.isDefined(rootSelfActivity, "capture should contain a /root self-activity"); + assert.equal(rootSelfActivity?.agentThreadId, rootThreadId); + }); + + it("routes every captured child method to a defined disposition", () => { + const childMethods = new Set( + notifications + .filter((entry) => { + const threadId = notificationThreadId(entry); + return threadId !== undefined && childThreadIds.has(threadId); + }) + .map((entry) => entry.method), + ); + assert.isAbove(childMethods.size, 0); + for (const method of childMethods) { + const route = routeCodexChildNotification(method); + // Child lifecycle traffic must become agent events — never silently + // dropped, never leaked to the parent timeline. + assert.equal(route, "agent-event", `${method} should map to an agent event`); + } + }); +}); + +describe("routeCodexChildNotification", () => { + it("maps child lifecycle to agent events", () => { + for (const method of [ + "turn/started", + "turn/completed", + "thread/status/changed", + "thread/tokenUsage/updated", + "item/started", + "item/completed", + "thread/closed", + "error", + ]) { + assert.equal(routeCodexChildNotification(method), "agent-event", method); + } + }); + + it("drops only enumerated child chatter", () => { + for (const method of [ + "item/agentMessage/delta", + "item/reasoning/textDelta", + "item/commandExecution/outputDelta", + "turn/plan/updated", + "thread/name/updated", + ]) { + assert.equal(routeCodexChildNotification(method), "drop", method); + } + }); + + it("never routes child-owned thread lifecycle to the parent", () => { + // These mutate PARENT thread state in CodexAdapter (archived/compacted), + // so a child emitting them must never reach the parent path. This list + // mirrors shouldSuppressChildConversationNotification (the v1 collab + // suppressor) — the two must not drift (review finding: the router + // initially omitted them and they leaked). + for (const method of [ + "thread/started", + "thread/status/changed", + "thread/archived", + "thread/unarchived", + "thread/closed", + "thread/compacted", + "thread/name/updated", + "thread/tokenUsage/updated", + "turn/started", + "turn/completed", + "turn/plan/updated", + "item/plan/delta", + ]) { + assert.notEqual( + routeCodexChildNotification(method), + "parent", + `${method} is child-owned and must not reach the parent path`, + ); + } + }); + + it("sends parent-owned and UNKNOWN methods to the parent path", () => { + // serverRequest/resolved clears the parent's approval correlation: + // swallowing it left approvals stuck (shipped bug). Unknown methods take + // the same route by design — a codex update that adds a notification + // must degrade to "parent sees it", never to silent loss. + assert.equal(routeCodexChildNotification("serverRequest/resolved"), "parent"); + assert.equal(routeCodexChildNotification("thread/somethingBrandNew"), "parent"); + assert.equal(routeCodexChildNotification("account/rateLimits/updated"), "parent"); + }); +}); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index f33bb4c0216..627b56feab4 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -610,6 +610,71 @@ function readRouteFields(notification: CodexServerNotification): { } } +/** + * Native collab child-agent tracking (multi-agent v2). Under v2 subagents are + * full app-server threads: identity arrives on `thread/started` with + * source.subAgent.thread_spawn, lifecycle on `subAgentActivity` items and the + * child thread's own turn/status/tokenUsage notifications. The runtime + * registers children from those explicit signals, intercepts their + * notifications before parent-timeline mapping, and re-emits them as + * synthetic `collabAgent/*` provider events the adapter turns into task.* + * runtime events (timelineBypass keeps them out of the parent chat). + * + * WIP, probe-gated: registration is deliberately explicit-signals-only. The + * spec's "provisionally treat unknown foreign thread ids as v2 children" rule + * needs a live wire capture of the packaged binary before it lands — blind + * capture risks eating unrelated traffic. Until then a child whose first + * notification precedes registration passes through as today (no regression + * vs main, which passes everything through). + */ +interface CollabChildAgentState { + readonly agentThreadId: string; + readonly nickname: string | undefined; + readonly role: string | undefined; + readonly agentPath: string | undefined; + readonly depth: number | undefined; + readonly parentThreadId: string | undefined; + /** + * Parent canonical turn active when the child registered. Stamped on every + * synthetic collabAgent/* event so clients can batch a fleet by its spawn + * turn — without it, separate fleets in one thread collapsed into a single + * "direct:no-turn" CTA (review finding). + */ + readonly spawnTurnId: TurnId | undefined; +} + +function readThreadSpawnSource(thread: { readonly source: unknown }): + | { + nickname: string | undefined; + role: string | undefined; + agentPath: string | undefined; + depth: number | undefined; + parentThreadId: string | undefined; + } + | undefined { + const source = thread.source; + if (typeof source !== "object" || source === null || !("subAgent" in source)) { + return undefined; + } + const subAgent = (source as { subAgent: unknown }).subAgent; + if (typeof subAgent !== "object" || subAgent === null || !("thread_spawn" in subAgent)) { + return undefined; + } + const spawn = (subAgent as { thread_spawn: unknown }).thread_spawn; + if (typeof spawn !== "object" || spawn === null) { + return undefined; + } + const record = spawn as Record; + return { + nickname: typeof record.agent_nickname === "string" ? record.agent_nickname : undefined, + role: typeof record.agent_role === "string" ? record.agent_role : undefined, + agentPath: typeof record.agent_path === "string" ? record.agent_path : undefined, + depth: typeof record.depth === "number" ? record.depth : undefined, + parentThreadId: + typeof record.parent_thread_id === "string" ? record.parent_thread_id : undefined, + }; +} + function rememberCollabReceiverTurns( collabReceiverTurns: Map, notification: CodexServerNotification, @@ -651,6 +716,72 @@ function shouldSuppressChildConversationNotification( ); } +/** + * How a notification addressed to a REGISTERED child thread is handled. + * + * Exported and pure so the routing table can be asserted against captured + * wire traces (see codexMultiAgentWire.json) rather than only read. + * + * - "agent-event": map to a synthetic collabAgent/* event (Agents surface). + * - "parent": pass through to the parent path — it carries state the parent + * still owns (approval correlation cleanup). + * - "drop": genuine child chatter with no parent meaning (deltas, name and + * plan updates). + * + * Default is "drop" ONLY for the enumerated chatter; anything unrecognized + * routes to "parent" so new wire methods surface instead of vanishing + * (two shipped bugs came from a catch-all that swallowed everything). + */ +export type CodexChildNotificationRoute = "agent-event" | "parent" | "drop"; + +const CHILD_AGENT_EVENT_METHODS: ReadonlySet = new Set([ + "turn/started", + "turn/completed", + "thread/status/changed", + "thread/tokenUsage/updated", + "item/started", + "item/completed", + "thread/closed", + "error", +]); + +const CHILD_CHATTER_METHODS: ReadonlySet = new Set([ + "item/agentMessage/delta", + "item/reasoning/textDelta", + "item/reasoning/summaryTextDelta", + "item/reasoning/summaryPartAdded", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/fileChange/patchUpdated", + "item/plan/delta", + "turn/plan/updated", + "turn/diff/updated", + "thread/name/updated", + "thread/settings/updated", + "rawResponseItem/completed", + // Child-owned thread lifecycle: the parent adapter maps these onto the + // PARENT thread (archived/compacted state), so a child compacting would + // rewrite the parent. Mirrors the v1 suppressor list — dropping them is + // the pre-existing behavior for collab children (review finding). + "thread/archived", + "thread/unarchived", + "thread/compacted", + // Registration path 1 handles a child's first thread/started; a repeat + // must not reach the parent (it would restart the parent's thread state). + "thread/started", +]); + +export function routeCodexChildNotification(method: string): CodexChildNotificationRoute { + if (CHILD_AGENT_EVENT_METHODS.has(method)) { + return "agent-event"; + } + if (CHILD_CHATTER_METHODS.has(method)) { + return "drop"; + } + // Unknown or parent-owned (serverRequest/resolved, approvals, …). + return "parent"; +} + function toCodexUserInputAnswer( questionId: string, value: ProviderUserInputAnswers[string], @@ -733,6 +864,9 @@ export const makeCodexSessionRuntime = ( const approvalCorrelationsRef = yield* Ref.make(new Map()); const pendingUserInputsRef = yield* Ref.make(new Map()); const collabReceiverTurnsRef = yield* Ref.make(new Map()); + const collabChildAgentsRef = yield* Ref.make(new Map()); + /** Child provider-thread id → its currently running provider turn id. */ + const collabChildLiveTurnsRef = yield* Ref.make(new Map()); const closedRef = yield* Ref.make(false); // `~` is not shell-expanded when env vars are set via @@ -849,6 +983,280 @@ export const makeCodexSessionRuntime = ( ), ); + /** + * Registers v2 collab children and re-emits their notifications as + * synthetic `collabAgent/*` events for the adapter's task.* synthesis. + * Returns true when the notification was fully handled (must not reach + * parent-timeline mapping). + */ + const interceptCollabChildNotification = (notification: CodexServerNotification) => + Effect.gen(function* () { + // Registration path 1: child thread announces itself with a + // subAgent thread_spawn source. + if (notification.method === "thread/started") { + const thread = notification.params.thread; + const spawn = readThreadSpawnSource(thread); + if (!spawn) { + return false; + } + // Merge with any subAgentActivity registration that got here + // first. spawnTurnId is REGISTRATION-time-only on both paths: for + // an already-known child we keep its value (set or unset) — a + // later thread/started during an unrelated parent turn must not + // backfill that turn as the spawn batch, which would stamp an old + // child onto a new fleet's CTA (review finding). Only a genuinely + // new registration captures the current turn. + const existingChild = (yield* Ref.get(collabChildAgentsRef)).get(thread.id); + const spawnTurnId = existingChild + ? existingChild.spawnTurnId + : ((yield* Ref.get(sessionRef)).activeTurnId ?? undefined); + const state: CollabChildAgentState = { + agentThreadId: thread.id, + nickname: spawn.nickname ?? thread.agentNickname ?? existingChild?.nickname, + role: spawn.role ?? thread.agentRole ?? existingChild?.role, + agentPath: spawn.agentPath ?? existingChild?.agentPath, + depth: spawn.depth ?? existingChild?.depth, + parentThreadId: + spawn.parentThreadId ?? thread.parentThreadId ?? existingChild?.parentThreadId, + spawnTurnId, + }; + yield* Ref.update(collabChildAgentsRef, (current) => { + const next = new Map(current); + next.set(thread.id, state); + return next; + }); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "collabAgent/started", + ...(state.spawnTurnId ? { turnId: state.spawnTurnId } : {}), + payload: { + agentThreadId: state.agentThreadId, + ...(state.nickname ? { nickname: state.nickname } : {}), + ...(state.role ? { role: state.role } : {}), + ...(state.agentPath ? { agentPath: state.agentPath } : {}), + ...(state.depth !== undefined ? { depth: state.depth } : {}), + ...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}), + }, + }); + return true; + } + + // Registration path 2: parent-side subAgentActivity item names the + // child thread (may arrive before or after thread/started). + if ( + (notification.method === "item/started" || notification.method === "item/completed") && + notification.params.item.type === "subAgentActivity" + ) { + const item = notification.params.item; + // Never register the session's ROOT thread as its own child. The + // wire emits subAgentActivity {agentPath: "/root", interacted} + // about the root during collab runs; registering it intercepted + // every subsequent root notification — including the final + // assistant message and turn/completed — so the thread hung + // "working" after all subagents finished (live-probe finding). + const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); + if ( + item.agentThreadId === rootProviderThreadId || + item.agentPath === "/root" || + item.agentPath === "/" + ) { + return false; + } + const activitySpawnTurnId = (yield* Ref.get(sessionRef)).activeTurnId ?? undefined; + yield* Ref.update(collabChildAgentsRef, (current) => { + const existing = current.get(item.agentThreadId); + const next = new Map(current); + // Merge-late semantics: when thread/started registered first, a + // later subAgentActivity still carries the real agentPath (and a + // derived nickname) — fill missing fields, never clobber known + // ones. spawnTurnId is registration-time-only: for an already + // registered child, a later activity during an UNRELATED turn + // must not backfill that turn as the spawn batch (review + // finding); an unset spawn turn stays unset. + next.set(item.agentThreadId, { + agentThreadId: item.agentThreadId, + nickname: + existing?.nickname ?? + item.agentPath.split("/").findLast((segment) => segment.length > 0), + role: existing?.role, + agentPath: existing?.agentPath ?? item.agentPath, + depth: existing?.depth, + parentThreadId: existing?.parentThreadId, + spawnTurnId: existing ? existing.spawnTurnId : activitySpawnTurnId, + }); + return next; + }); + const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "collabAgent/activity", + ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}), + payload: { + agentThreadId: item.agentThreadId, + agentPath: item.agentPath, + activityKind: item.kind, + }, + }); + return true; + } + + // Interception: notifications addressed to a registered child thread + // become agent-scoped synthetic events instead of parent chatter. + const providerConversationId = readNotificationThreadId(notification); + if (!providerConversationId) { + return false; + } + // Belt-and-braces: the root thread's traffic must never be + // intercepted, whatever the registry says. + const interceptRootId = currentProviderThreadId(yield* Ref.get(sessionRef)); + if (providerConversationId === interceptRootId) { + return false; + } + const children = yield* Ref.get(collabChildAgentsRef); + const child = children.get(providerConversationId); + if (!child) { + return false; + } + const childIdentity = { + agentThreadId: child.agentThreadId, + ...(child.nickname ? { nickname: child.nickname } : {}), + ...(child.role ? { role: child.role } : {}), + ...(child.agentPath ? { agentPath: child.agentPath } : {}), + }; + switch (notification.method) { + case "turn/started": { + const childTurnId = + typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" + ? ((notification.params as { turn: { id: string } }).turn.id as string) + : undefined; + if (childTurnId) { + yield* Ref.update(collabChildLiveTurnsRef, (current) => { + const next = new Map(current); + next.set(child.agentThreadId, childTurnId); + return next; + }); + } + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/turnStarted", + payload: childIdentity, + }); + return true; + } + case "turn/completed": + yield* Ref.update(collabChildLiveTurnsRef, (current) => { + const next = new Map(current); + next.delete(child.agentThreadId); + return next; + }); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/turnCompleted", + payload: { + ...childIdentity, + turn: notification.params.turn, + }, + }); + return true; + case "thread/status/changed": + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/statusChanged", + payload: { + ...childIdentity, + status: notification.params.status, + }, + }); + return true; + case "thread/tokenUsage/updated": + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/tokenUsage", + payload: { + ...childIdentity, + tokenUsage: notification.params.tokenUsage, + }, + }); + return true; + case "item/started": + case "item/completed": + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/item", + payload: { + ...childIdentity, + item: notification.params.item, + }, + }); + return true; + case "thread/closed": + // The child is gone: drop its live-turn entry so a later Stop + // doesn't waste a turn/interrupt RPC on a closed thread before + // reaching the parent (review finding). + yield* Ref.update(collabChildLiveTurnsRef, (current) => { + const next = new Map(current); + next.delete(child.agentThreadId); + return next; + }); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/closed", + payload: childIdentity, + }); + return true; + case "error": { + // A child error must surface as a failed agent, not vanish into + // the default swallow (review finding: the child stayed + // "running" forever). Retryable errors (willRetry) keep the + // child RUNNING and interruptible — mirroring the root error + // handler; settling it would orphan a still-live child from + // Stop (review finding). Terminal errors clean up the live turn + // like thread/closed and reuse the statusChanged systemError + // path. + const willRetry = (notification.params as { willRetry?: boolean }).willRetry === true; + if (willRetry) { + return true; + } + yield* Ref.update(collabChildLiveTurnsRef, (current) => { + const next = new Map(current); + next.delete(child.agentThreadId); + return next; + }); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/statusChanged", + payload: { + ...childIdentity, + status: { type: "systemError" }, + }, + }); + return true; + } + default: + // Routing table decides (single source of truth, asserted + // against captured wire traces): enumerated chatter is dropped, + // everything else — including methods this build has never seen + // — falls through to the parent path rather than vanishing. + return routeCodexChildNotification(notification.method) === "drop"; + } + }); + const handleRawNotification = (notification: CodexServerNotification) => Effect.gen(function* () { const payload = notification.params; @@ -862,7 +1270,68 @@ export const makeCodexSessionRuntime = ( })(); rememberCollabReceiverTurns(collabReceiverTurns, notification, route.turnId); - if (childParentTurnId && shouldSuppressChildConversationNotification(notification.method)) { + // Interception FIRST: a registered v2 child is usually also in the + // receiver-turn map (collabAgentToolCall.receiverThreadIds), and the + // legacy suppressor below would drop its lifecycle before it could + // become synthetic collabAgent events (review finding). The + // suppressor still covers UNREGISTERED children. + if (yield* interceptCollabChildNotification(notification)) { + yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); + return; + } + + // Suppression applies to receiver-map children (v1) AND to any + // conversation that is not the root thread. The live capture + // (codexMultiAgentWire.json) shows a child's thread/status/changed + // arriving BEFORE anything registers the child — pre-registration + // lifecycle must not reach the parent path, where the adapter maps + // thread/* onto parent session state. Root-id-known guard keeps the + // root's own early notifications flowing during session open. + const suppressRootId = currentProviderThreadId(yield* Ref.get(sessionRef)); + const foreignConversation = (() => { + const providerConversationId = readNotificationThreadId(notification); + return ( + providerConversationId !== undefined && + suppressRootId !== undefined && + providerConversationId !== suppressRootId + ); + })(); + if ( + (childParentTurnId !== undefined || foreignConversation) && + shouldSuppressChildConversationNotification(notification.method) + ) { + // Stop-everything must not depend on registration timing: a + // child's turn/started can arrive before the subAgentActivity that + // registers it (captured ordering), and suppressing it without + // remembering the live turn would leave that child running after + // Stop (review finding). Track live turns for ANY foreign + // conversation; interrupts are best-effort per child, so a + // false-positive entry costs one ignored RPC at worst. + const foreignThreadId = readNotificationThreadId(notification); + if (foreignThreadId !== undefined) { + if (notification.method === "turn/started") { + const foreignTurnId = + typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" + ? (notification.params as { turn: { id: string } }).turn.id + : undefined; + if (foreignTurnId) { + yield* Ref.update(collabChildLiveTurnsRef, (current) => { + const next = new Map(current); + next.set(foreignThreadId, foreignTurnId); + return next; + }); + } + } else if ( + notification.method === "turn/completed" || + notification.method === "thread/closed" + ) { + yield* Ref.update(collabChildLiveTurnsRef, (current) => { + const next = new Map(current); + next.delete(foreignThreadId); + return next; + }); + } + } yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); return; } @@ -1387,6 +1856,26 @@ export const makeCodexSessionRuntime = ( Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; const session = yield* Ref.get(sessionRef); + // Stop-everything: children are full threads with their own turns; + // interrupting only the parent leaves the fleet running. Interrupt + // each live child turn first, best-effort per child, BOUNDED: the + // transport awaits an unbounded Deferred per request, so a wedged + // child would otherwise block the parent interrupt forever — + // exactly during the runaway fleet where Stop matters most + // (review finding). Per-child and overall deadlines guarantee the + // parent interrupt below always runs. + const liveChildTurns = yield* Ref.get(collabChildLiveTurnsRef); + yield* Effect.forEach( + Array.from(liveChildTurns.entries()), + ([childThreadId, childTurnId]) => + client + .request("turn/interrupt", { + threadId: childThreadId, + turnId: childTurnId, + }) + .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), + { concurrency: 8, discard: true }, + ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); const effectiveTurnId = turnId ?? session.activeTurnId; if (!effectiveTurnId) { return; diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs new file mode 100644 index 00000000000..59580d2c7e6 --- /dev/null +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -0,0 +1,93 @@ +// Minimal codex app-server stand-in for runtime-level collab tests. +// Speaks just enough of the protocol for CodexSessionRuntime to start a +// session, using REAL captured responses (codexMultiAgentWire.json), then +// replays a scripted multi-agent notification sequence read from the +// T3_CODEX_COLLAB_SCRIPT env var (a JSON file path) when the first turn +// starts. Runs as a plain Node process — stdlib only. +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const here = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const fixture = JSON.parse( + NodeFS.readFileSync(NodePath.join(here, "codexMultiAgentWire.json"), "utf8"), +); +const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT, "utf8")); + +const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); + +const rl = NodeReadline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + const { id, method } = message; + if (method === "initialize") { + write({ + id, + result: { + userAgent: "t3-collab-mock/0.0.0", + codexHome: "/tmp", + platformFamily: "unix", + platformOs: "linux", + }, + }); + return; + } + if (method === "thread/start" || method === "thread/resume") { + write({ id, result: fixture.responses.threadStart }); + return; + } + if (method === "turn/start") { + write({ id, result: fixture.responses.turnStart }); + const rootThreadId = script.rootThreadId; + const turn = fixture.responses.turnStart.turn; + write({ + jsonrpc: "2.0", + method: "turn/started", + params: { threadId: rootThreadId, turn }, + }); + for (const notification of script.notifications) { + write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); + } + if (script.holdTurnOpen !== true) { + write({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + threadId: rootThreadId, + turn: { ...turn, status: "completed" }, + }, + }); + } + return; + } + if (method === "turn/interrupt") { + // Record which thread/turn was interrupted (append-only sidecar file the + // test reads) so Stop coverage can assert every live child was reached. + // failInterruptFor simulates a dead child whose interrupt errors. + const target = message.params?.threadId; + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.interrupts`, + `${JSON.stringify({ threadId: target, turnId: message.params?.turnId })}\n`, + ); + if (script.failInterruptFor && script.failInterruptFor === target) { + write({ id, error: { code: -32000, message: "thread already closed" } }); + return; + } + if (script.hangInterruptFor && script.hangInterruptFor === target) { + // Never respond: simulates a wedged child whose RPC neither resolves + // nor rejects. The runtime's bounded deadline must move on. + return; + } + write({ id, result: {} }); + return; + } + if (id !== undefined) { + write({ id, result: {} }); + } +}); diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.sh b/apps/server/src/provider/testFixtures/codexCollabMockPeer.sh new file mode 100755 index 00000000000..f6a680a4992 --- /dev/null +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Wrapper so CodexSessionRuntime can spawn the mock peer: the runtime always +# passes "app-server" as the first argument (real codex CLI subcommand); +# discard it and exec node on the .mjs peer. +shift +exec node "$(dirname "$0")/codexCollabMockPeer.mjs" "$@" diff --git a/apps/server/src/provider/testFixtures/codexMultiAgentWire.json b/apps/server/src/provider/testFixtures/codexMultiAgentWire.json new file mode 100644 index 00000000000..08316d3b633 --- /dev/null +++ b/apps/server/src/provider/testFixtures/codexMultiAgentWire.json @@ -0,0 +1,445 @@ +{ + "capturedWith": { + "cli": "codex-cli 0.145.0", + "model": "gpt-5.6-luna", + "effort": "low" + }, + "rootThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "childThreadIds": [ + "019fcfd6-2883-77e0-9013-4410ede70371", + "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf" + ], + "notifications": [ + { + "method": "thread/started", + "params": { + "thread": { + "id": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "extra": null, + "sessionId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "forkedFromId": null, + "parentThreadId": null, + "preview": "", + "ephemeral": false, + "historyMode": "legacy", + "modelProvider": "openai", + "createdAt": 1785898342, + "updatedAt": 1785898342, + "recencyAt": 1785898342, + "status": { + "type": "idle" + }, + "path": "/fixtures/codex/rollout.jsonl", + "cwd": "/workspace/repo", + "cliVersion": "0.145.0", + "source": "vscode", + "canAcceptDirectInput": true, + "threadSource": null, + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "turns": [] + } + } + }, + { + "method": "thread/status/changed", + "params": { + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "status": { + "type": "active", + "activeFlags": [] + } + } + }, + { + "method": "turn/started", + "params": { + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turn": { + "id": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "items": [], + "itemsView": "notLoaded", + "status": "inProgress", + "error": null, + "startedAt": 1785898342, + "completedAt": null, + "durationMs": null + } + } + }, + { + "method": "thread/status/changed", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "status": { + "type": "idle" + } + } + }, + { + "method": "item/completed", + "params": { + "item": { + "type": "subAgentActivity", + "id": "call_S2JPiq0sMnJYS7kswVdvzEjB", + "kind": "started", + "agentThreadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "agentPath": "/root/alpha" + }, + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "completedAtMs": 1785898346687 + } + }, + { + "method": "thread/tokenUsage/updated", + "params": { + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "tokenUsage": { + "total": { + "totalTokens": 18261, + "inputTokens": 18228, + "cachedInputTokens": 11008, + "cacheWriteInputTokens": 0, + "outputTokens": 33, + "reasoningOutputTokens": 0 + }, + "last": { + "totalTokens": 18261, + "inputTokens": 18228, + "cachedInputTokens": 11008, + "cacheWriteInputTokens": 0, + "outputTokens": 33, + "reasoningOutputTokens": 0 + }, + "modelContextWindow": 258400 + } + } + }, + { + "method": "thread/status/changed", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "status": { + "type": "active", + "activeFlags": [] + } + } + }, + { + "method": "turn/started", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "turn": { + "id": "019fcfd6-28bf-7e00-a873-4554526dc845", + "items": [], + "itemsView": "notLoaded", + "status": "inProgress", + "error": null, + "startedAt": 1785898346, + "completedAt": null, + "durationMs": null + } + } + }, + { + "method": "thread/status/changed", + "params": { + "threadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf", + "status": { + "type": "idle" + } + } + }, + { + "method": "item/completed", + "params": { + "item": { + "type": "subAgentActivity", + "id": "call_A2U7sfjmdGlUn5bn6gUNI0rl", + "kind": "started", + "agentThreadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf", + "agentPath": "/root/beta" + }, + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "completedAtMs": 1785898348329 + } + }, + { + "method": "thread/status/changed", + "params": { + "threadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf", + "status": { + "type": "active", + "activeFlags": [] + } + } + }, + { + "method": "turn/started", + "params": { + "threadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf", + "turn": { + "id": "019fcfd6-2f29-79e3-aa6a-c5836a519d3f", + "items": [], + "itemsView": "notLoaded", + "status": "inProgress", + "error": null, + "startedAt": 1785898348, + "completedAt": null, + "durationMs": null + } + } + }, + { + "method": "thread/tokenUsage/updated", + "params": { + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "tokenUsage": { + "total": { + "totalTokens": 36576, + "inputTokens": 36510, + "cachedInputTokens": 28160, + "cacheWriteInputTokens": 0, + "outputTokens": 66, + "reasoningOutputTokens": 0 + }, + "last": { + "totalTokens": 18315, + "inputTokens": 18282, + "cachedInputTokens": 17152, + "cacheWriteInputTokens": 0, + "outputTokens": 33, + "reasoningOutputTokens": 0 + }, + "modelContextWindow": 258400 + } + } + }, + { + "method": "item/completed", + "params": { + "item": { + "type": "subAgentActivity", + "id": "call_2W1NboHClVo0VqAf1y3ZFinO", + "kind": "interacted", + "agentThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "agentPath": "/root" + }, + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "turnId": "019fcfd6-28bf-7e00-a873-4554526dc845", + "completedAtMs": 1785898349265 + } + }, + { + "method": "thread/tokenUsage/updated", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "turnId": "019fcfd6-28bf-7e00-a873-4554526dc845", + "tokenUsage": { + "total": { + "totalTokens": 20756, + "inputTokens": 20713, + "cachedInputTokens": 5888, + "cacheWriteInputTokens": 0, + "outputTokens": 43, + "reasoningOutputTokens": 11 + }, + "last": { + "totalTokens": 20756, + "inputTokens": 20713, + "cachedInputTokens": 5888, + "cacheWriteInputTokens": 0, + "outputTokens": 43, + "reasoningOutputTokens": 11 + }, + "modelContextWindow": 258400 + } + } + }, + { + "method": "item/started", + "params": { + "item": { + "type": "collabAgentToolCall", + "id": "call_XGMDYMm4O8ne7vg3Y6p4eSqz", + "tool": "wait", + "status": "inProgress", + "senderThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "receiverThreadIds": [], + "prompt": null, + "model": null, + "reasoningEffort": null, + "agentsStates": {} + }, + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "startedAtMs": 1785898349931 + } + }, + { + "method": "item/completed", + "params": { + "item": { + "type": "collabAgentToolCall", + "id": "call_XGMDYMm4O8ne7vg3Y6p4eSqz", + "tool": "wait", + "status": "completed", + "senderThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "receiverThreadIds": [], + "prompt": null, + "model": null, + "reasoningEffort": null, + "agentsStates": {} + }, + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "completedAtMs": 1785898349931 + } + }, + { + "method": "thread/tokenUsage/updated", + "params": { + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "tokenUsage": { + "total": { + "totalTokens": 54933, + "inputTokens": 54846, + "cachedInputTokens": 45312, + "cacheWriteInputTokens": 0, + "outputTokens": 87, + "reasoningOutputTokens": 0 + }, + "last": { + "totalTokens": 18357, + "inputTokens": 18336, + "cachedInputTokens": 17152, + "cacheWriteInputTokens": 0, + "outputTokens": 21, + "reasoningOutputTokens": 0 + }, + "modelContextWindow": 258400 + } + } + }, + { + "method": "thread/tokenUsage/updated", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "turnId": "019fcfd6-28bf-7e00-a873-4554526dc845", + "tokenUsage": { + "total": { + "totalTokens": 41529, + "inputTokens": 41481, + "cachedInputTokens": 26112, + "cacheWriteInputTokens": 0, + "outputTokens": 48, + "reasoningOutputTokens": 11 + }, + "last": { + "totalTokens": 20773, + "inputTokens": 20768, + "cachedInputTokens": 20224, + "cacheWriteInputTokens": 0, + "outputTokens": 5, + "reasoningOutputTokens": 0 + }, + "modelContextWindow": 258400 + } + } + }, + { + "method": "thread/status/changed", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "status": { + "type": "idle" + } + } + }, + { + "method": "turn/completed", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "turn": { + "id": "019fcfd6-28bf-7e00-a873-4554526dc845", + "items": [], + "itemsView": "notLoaded", + "status": "completed", + "error": null, + "startedAt": 1785898346, + "completedAt": 1785898350, + "durationMs": 3792 + } + } + } + ], + "responses": { + "threadStart": { + "thread": { + "id": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "extra": null, + "sessionId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "forkedFromId": null, + "parentThreadId": null, + "preview": "", + "ephemeral": false, + "historyMode": "legacy", + "modelProvider": "openai", + "createdAt": 1785898342, + "updatedAt": 1785898342, + "recencyAt": 1785898342, + "status": { + "type": "idle" + }, + "path": "/fixtures/codex/rollout.jsonl", + "cwd": "/workspace/repo", + "cliVersion": "0.145.0", + "source": "vscode", + "canAcceptDirectInput": true, + "threadSource": null, + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "turns": [] + }, + "model": "gpt-5.6-sol", + "modelProvider": "openai", + "serviceTier": "default", + "cwd": "/workspace/repo", + "runtimeWorkspaceRoots": ["/workspace/repo"], + "instructionSources": ["/fixtures/scrubbed", "/workspace/repo/AGENTS.md"], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandbox": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "activePermissionProfile": null, + "reasoningEffort": "high", + "multiAgentMode": "explicitRequestOnly" + }, + "turnStart": { + "turn": { + "id": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "items": [], + "itemsView": "notLoaded", + "status": "inProgress", + "error": null, + "startedAt": null, + "completedAt": null, + "durationMs": null + } + } + } +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8361214bf2a..a6eddd116dc 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -716,8 +716,13 @@ export const makeServerLayer = Layer.unwrap( const server = yield* HttpServer.HttpServer; const address = server.address; if (typeof address === "string" || !("port" in address)) return; - yield* Effect.sleep("250 millis").pipe( - Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), + // No settling delay before the first attempt: routes are already + // serving by the time activation opens this gate (the startup + // sequence awaits routesReady), and the retry schedule below + // covers anything this sleep used to hedge against. Every + // millisecond here is dead time on the path to remote + // reachability after a restart. + yield* reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`).pipe( Effect.retry({ while: (error) => error._tag !== "EnvironmentHttpBadRequestError" && diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a649caa77fe..8091e194658 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -99,6 +99,7 @@ import * as PortScanner from "./preview/PortScanner.ts"; import * as AiUsageMonitorModule from "./aiUsage/AiUsageMonitor.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import { readWorkflowScript } from "./orchestration/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; @@ -1356,6 +1357,12 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.getWorkflowScript]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getWorkflowScript, + readWorkflowScript({ scriptPath: input.scriptPath }), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.getTurnDiff, diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx new file mode 100644 index 00000000000..169c662e585 --- /dev/null +++ b/apps/web/src/components/AgentsPanel.tsx @@ -0,0 +1,568 @@ +/** + * Agents right-panel surface: the fleet view over the native subagent fold, + * and the ONLY place the roster renders (the chat carries one CTA row per + * spawn batch). + * + * Visualization rules (from live-test feedback): + * - Live work first: running workflows and direct spawns sort above settled. + * - Rows are flat status lines — no expansion, no per-agent tool feeds. The + * row answers "who / what phase / how much"; anything deeper is a future + * drill-in, not an unfold. + * - A settled workflow run collapses to a single summary line; click it to + * show its member list inline (the one allowed toggle — run granularity, + * not agent granularity). + * - Static status dots, DOM-write elapsed timers, plain token counters. + */ +import { useAtomValue } from "@effect/atom-react"; +import type { + AgentPanelModel, + AgentPanelWorkflowGroup, + RuntimeSubagent, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import { + formatSubagentModelLabel, + formatSubagentTokenCount, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { Bot, Braces, Check, ChevronDown, ChevronRight, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; +import { orchestrationEnvironment } from "~/state/orchestration"; +import { ScrollArea } from "~/components/ui/scroll-area"; + +/** + * In-flight states all present as Working (one steady state, per the + * monitoring-pill design: detail belongs in the activity sub-line, and a + * stalled/waiting/queued subagent is still the fleet doing its job, not a + * user problem). Only settled states differentiate. + */ +const STATUS_VISUALS: Record = { + pending: { dotClass: "bg-info", label: "Working" }, + running: { dotClass: "bg-info", label: "Working" }, + waiting: { dotClass: "bg-info", label: "Working" }, + // Idle reads as settled (muted, not sky): a resting Codex child looks done + // unless resumed — live-test: sky idle dots read as stuck in-progress. + idle: { dotClass: "bg-muted-foreground/50", label: "Idle · resumable" }, + completed: { dotClass: "bg-success", label: "Completed" }, + failed: { dotClass: "bg-destructive", label: "Failed" }, + cancelled: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, + interrupted: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, +}; + +function StatusDot({ status }: { status: RuntimeSubagent["status"] }) { + return ( + + ); +} + +function formatElapsedSeconds(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const minutes = Math.floor(seconds / 60); + if (minutes === 0) { + return `${seconds}s`; + } + const hours = Math.floor(minutes / 60); + if (hours === 0) { + return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`; + } + return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; +} + +function elapsedBetween(startedAt: string, endIso: string | null): string { + const start = Date.parse(startedAt); + const end = endIso ? Date.parse(endIso) : Date.now(); + if (Number.isNaN(start) || Number.isNaN(end)) { + return ""; + } + return formatElapsedSeconds((end - start) / 1000); +} + +/** + * Elapsed time for the current activation. Live agents self-tick via DOM + * writes (zero React commits per tick); settled agents freeze at completedAt. + */ +function AgentElapsed({ agent }: { agent: RuntimeSubagent }) { + const textRef = useRef(null); + const live = agent.status === "running" || agent.status === "waiting"; + const startedAt = agent.startedAt; + + useEffect(() => { + if (!live || !startedAt) { + return; + } + const update = () => { + if (textRef.current) { + textRef.current.textContent = elapsedBetween(startedAt, null); + } + }; + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [live, startedAt]); + + if (!startedAt) { + return null; + } + return ( + + {elapsedBetween(startedAt, live ? null : agent.completedAt)} + + ); +} + +/** + * Status-dependent activity line. Live rows lead with what is happening now; + * settled rows lead with the outcome. Errors are the only inline previews on + * failed rows because they explain a red row at a glance. + */ +function agentActivityText(agent: RuntimeSubagent): string | null { + const live = + agent.status === "running" || agent.status === "pending" || agent.status === "waiting"; + if (live) { + return ( + agent.progress ?? + (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) ?? + agent.result ?? + agent.error + ); + } + return ( + agent.error ?? + agent.result ?? + agent.progress ?? + (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) + ); +} + +/** Flat, non-interactive agent status line. No unfold. */ +function AgentRow({ agent }: { agent: RuntimeSubagent }) { + const visuals = STATUS_VISUALS[agent.status]; + const activity = agentActivityText(agent); + const modelLabel = formatSubagentModelLabel(agent.model, agent.effort); + + return ( +
+
+ + + + + + {agent.title} + {agent.role ? ( + + {agent.role} + + ) : null} + + + {agent.status === "completed" ? ( + + ) : null} + + + {activity ? ( + + {activity} + + ) : null} + + {modelLabel ? {modelLabel} : null} + {agent.usage ? ( + + {modelLabel ? "· " : ""} + {formatSubagentTokenCount(agent.usage.totalTokens)} tok + + ) : null} + {agent.usage?.toolUses !== undefined ? ( + · {agent.usage.toolUses} tools + ) : null} + {agent.activationCount > 1 ? · run {agent.activationCount} : null} + {visuals.label} + + +
+
+ ); +} + +function workflowIsLive(group: AgentPanelWorkflowGroup): boolean { + const status = group.workflow.status; + return ( + status !== "completed" && + status !== "failed" && + status !== "cancelled" && + status !== "interrupted" + ); +} + +function workflowMembers(group: AgentPanelWorkflowGroup): ReadonlyArray { + return [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; +} + +/** + * Phase rail: the run's shape at a glance. One segment per phase in order, + * separated by chevrons; each segment shows title + one dot per member. + * The whole arc (done → live → pending) is visible without scrolling the + * member list. + */ +function PhaseRail({ group }: { group: AgentPanelWorkflowGroup }) { + if (group.phases.length === 0) { + return null; + } + return ( +
+ {group.phases.map((phase, index) => ( +
+ {index > 0 ? ( + + ) : null} +
+ + {phase.state === "done" ? "✓ " : ""} + {phase.title} + + + {phase.members.length === 0 ? ( + + ) : ( + phase.members.map((member) => ) + )} + +
+
+ ))} +
+ ); +} + +/** + * Read-only workflow script viewer, fetched through the contained + * getWorkflowScript RPC (never a raw filesystem read from the client). + */ +function WorkflowScriptView({ + environmentId, + threadId, + scriptPath, + onClose, +}: { + environmentId: EnvironmentId; + threadId: ThreadId; + scriptPath: string; + onClose: () => void; +}) { + const result = useAtomValue( + orchestrationEnvironment.workflowScript({ environmentId, input: { threadId, scriptPath } }), + ); + return ( +
+
+ + + {scriptPath.split("/").at(-1)} + + +
+
+ {result._tag === "Success" ? ( +
+            {result.value.contents}
+            {result.value.truncated ? "\n… (truncated)" : ""}
+          
+ ) : result._tag === "Failure" ? ( +

Could not load the script.

+ ) : ( +

Loading…

+ )} +
+
+ ); +} + +/** + * Collapsible phase section (Claude Code Background-tasks pattern): live + * phases open by default, done phases collapsed to header + member dot row. + * User toggles override the default and stick for the phase's lifetime. + */ +function PhaseSection({ phase }: { phase: AgentPanelWorkflowGroup["phases"][number] }) { + const [userOpen, setUserOpen] = useState(null); + const open = userOpen ?? phase.state === "running"; + return ( +
+ + {open ? phase.members.map((member) => ) : null} +
+ ); +} + +/** Live workflow: phase rail + full phase tree. */ +function LiveWorkflowSection({ + group, + environmentId, + threadId, +}: { + group: AgentPanelWorkflowGroup; + environmentId: EnvironmentId | null; + threadId: ThreadId | null; +}) { + const [scriptOpen, setScriptOpen] = useState(false); + const members = workflowMembers(group); + const settled = members.filter( + (member) => + member.status === "completed" || + member.status === "failed" || + member.status === "cancelled" || + member.status === "interrupted", + ).length; + const scriptPath = group.workflow.runHandles?.scriptPath; + const canShowScript = scriptPath !== undefined && environmentId !== null && threadId !== null; + return ( +
+
+ + {group.workflow.workflowName ?? group.workflow.title} + {canShowScript ? ( + + ) : null} + + {settled}/{members.length} settled + +
+ + {scriptOpen && canShowScript ? ( + setScriptOpen(false)} + /> + ) : null} + {group.phases.map((phase) => ( + + ))} + {group.unphasedMembers.map((member) => ( + + ))} + {group.phases.length === 0 && group.unphasedMembers.length === 0 ? ( + + ) : null} +
+ ); +} + +/** + * Settled workflow: one summary line. Click toggles the member list — the + * only expansion in the panel, at run granularity. + */ +function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) { + const [open, setOpen] = useState(false); + const members = workflowMembers(group); + const failed = members.filter((member) => member.status === "failed").length; + // Coordinator usage may already aggregate members (panel-footer rule): + // count it only when there are no member rows to sum. + const totalTokens = members.reduce( + (sum, member) => sum + (member.usage?.totalTokens ?? 0), + members.length === 0 ? (group.workflow.usage?.totalTokens ?? 0) : 0, + ); + const elapsed = + group.workflow.startedAt && group.workflow.completedAt + ? elapsedBetween(group.workflow.startedAt, group.workflow.completedAt) + : null; + return ( +
+ + {open ? ( +
+ {members.map((member) => ( + + ))} +
+ ) : null} +
+ ); +} + +export function AgentsPanel({ + model, + environmentId = null, + threadId = null, +}: { + model: AgentPanelModel; + environmentId?: EnvironmentId | null; + threadId?: ThreadId | null; +}) { + if (!model.hasAgents) { + return ( +
+ +

No agents yet

+

+ When this thread spawns subagents or runs a workflow, they show up here with live status, + activity, and token usage. +

+
+ ); + } + + const liveWorkflows = model.workflows.filter(workflowIsLive); + const settledWorkflows = model.workflows.filter((group) => !workflowIsLive(group)); + const liveDirect = model.directAgents.filter( + (agent) => + agent.status === "running" || agent.status === "pending" || agent.status === "waiting", + ); + const settledDirect = model.directAgents.filter( + (agent) => + agent.status !== "running" && agent.status !== "pending" && agent.status !== "waiting", + ); + + return ( +
+ +
+ {liveWorkflows.map((group) => ( + + ))} + {liveDirect.length > 0 ? ( +
+
+ Direct spawns +
+ {liveDirect.map((agent) => ( + + ))} +
+ ) : null} + {settledWorkflows.length > 0 || settledDirect.length > 0 ? ( +
+
+ Earlier +
+ {settledWorkflows.map((group) => ( + + ))} + {settledDirect.map((agent) => ( + + ))} +
+ ) : null} +
+
+
+ + {model.runningCount + model.waitingCount > 0 ? ( + + ● {model.runningCount + model.waitingCount} working + + ) : null} + {model.idleCount > 0 ? {model.idleCount} idle : null} + {model.settledCount > 0 ? {model.settledCount} settled : null} + + Σ {formatSubagentTokenCount(model.totalTokens)} tok +
+
+ ); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c329f3620d4..1440a386384 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -152,6 +152,11 @@ import { usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; import { RightPanelTabs } from "./RightPanelTabs"; +import { AgentsPanel } from "./AgentsPanel"; +import { + deriveAgentPanelModel, + foldSubagentActivities, +} from "@t3tools/client-runtime/state/subagentRuntime"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -2207,6 +2212,18 @@ function ChatViewContent(props: ChatViewProps) { }); const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); + // Native subagent fold: memoized by activity-list identity, shared by the + // Agents surface, live strip, and workflow cards. v2Projection is null + // until orchestration-v2 lands (source precedence lives in the derive). + // sessionLive derives interruption for agents orphaned by session death. + const agentSessionLive = phase !== "disconnected"; + const agentPanelModel = useMemo( + () => + deriveAgentPanelModel({ + agents: foldSubagentActivities(threadActivities, { sessionLive: agentSessionLive }), + }), + [agentSessionLive, threadActivities], + ); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], @@ -3335,6 +3352,10 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); }, [activeProject, activeThreadRef]); + const addAgentsSurface = useCallback(() => { + if (!activeThreadRef) return; + useRightPanelStore.getState().open(activeThreadRef, "agents"); + }, [activeThreadRef]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -4501,6 +4522,85 @@ function ChatViewContent(props: ChatViewProps) { switchGitRef, updateThreadMetadata, ]); + // Background work (subagent fleets, workflow runs, watch loops) can outlive + // the turn; once it settles, the composer stop button is gone, so this + // banner is the only visible stop affordance. Stop routes through the + // stop-everything interrupt: it kills every live background task before + // interrupting, and works by session, so no active turn is needed. + const activeBackgroundLiveness = + !isWorking && activeThread ? (activeThreadShell?.backgroundLiveness ?? null) : null; + const [isStoppingBackgroundWork, setIsStoppingBackgroundWork] = useState(false); + useEffect(() => { + // "Stopping..." holds until the liveness clears; the interrupt command + // returning only means the request was accepted. + if (activeBackgroundLiveness === null) { + setIsStoppingBackgroundWork(false); + } + }, [activeBackgroundLiveness]); + useEffect(() => { + // Per-thread state: switching threads while A's stop is pending must not + // disable B's Stop button (review finding). + setIsStoppingBackgroundWork(false); + }, [activeThreadId]); + const handleStopBackgroundWork = useCallback(async () => { + if (!activeThread) return; + setIsStoppingBackgroundWork(true); + const result = await interruptThreadTurn({ + environmentId, + input: buildThreadTurnInterruptInput(activeThread), + }); + if (result._tag === "Failure") { + // Every failure clears the pending state — an interrupted command + // never reached the server, so liveness would hold "Stopping..." + // forever. Only real failures toast. + setIsStoppingBackgroundWork(false); + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to stop background work.", + ); + } + } + }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); + const backgroundLivenessBannerItem = useMemo(() => { + if (activeBackgroundLiveness === null || !activeThread) { + return null; + } + const working = activeBackgroundLiveness === "working"; + const liveCount = agentPanelModel.liveCount; + return { + id: `background-liveness:${activeThread.id}`, + variant: "info", + icon: ( +