| author | |
| committer | |
| log | a2ca89aa10f13a2222e08afd98c66285121d5ba2 |
| tree | 8e74d91573299f147977e350dcdbe7c13c7a9704 |
| parent | 990bb0b689b23a33f8e53f12962fe2542b3f4005 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>46 files changed, 6871 insertions(+), 71 deletions(-)
apps/mobile/src/lib/threadActivity.test.ts+36| ... | @@ -532,3 +532,39 @@ describe("buildThreadFeed", () => { | ... | @@ -532,3 +532,39 @@ describe("buildThreadFeed", () => { |
| 532 | }); | 532 | }); |
| 533 | }); | 533 | }); |
| 534 | }); | 534 | }); |
| 535 | |||
| 536 | describe("quiet timeline: nested agents", () => { | ||
| 537 | it("keeps a nested agent's terminal row but hides its background work", () => { | ||
| 538 | const thread = makeThread({ | ||
| 539 | id: ThreadId.make("thread-nested"), | ||
| 540 | projectId: ProjectId.make("project-1"), | ||
| 541 | title: "Nested agents", | ||
| 542 | activities: [ | ||
| 543 | // A subagent's own shell: internal, covered by the owner's liveness. | ||
| 544 | makeActivity({ | ||
| 545 | id: EventId.make("shell-done"), | ||
| 546 | kind: "task.completed", | ||
| 547 | summary: "Task completed", | ||
| 548 | createdAt: "2026-04-01T00:00:02.000Z", | ||
| 549 | payload: { taskId: "sh-1", agentId: "owner", agentKind: "background" }, | ||
| 550 | }), | ||
| 551 | // A nested AGENT's completion: mobile has no Agents sheet, so this | ||
| 552 | // terminal row is the only signal it ever finished. | ||
| 553 | makeActivity({ | ||
| 554 | id: EventId.make("nested-done"), | ||
| 555 | kind: "task.completed", | ||
| 556 | summary: "Task completed", | ||
| 557 | createdAt: "2026-04-01T00:00:03.000Z", | ||
| 558 | payload: { taskId: "n-1", agentId: "owner", agentKind: "agent" }, | ||
| 559 | }), | ||
| 560 | ], | ||
| 561 | }); | ||
| 562 | |||
| 563 | const feed = buildThreadFeed(thread); | ||
| 564 | const ids = feed.flatMap((entry) => | ||
| 565 | entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [], | ||
| 566 | ); | ||
| 567 | expect(ids).toContain("nested-done"); | ||
| 568 | expect(ids).not.toContain("shell-done"); | ||
| 569 | }); | ||
| 570 | }); |
apps/mobile/src/lib/threadActivity.ts+93-1| ... | @@ -80,6 +80,8 @@ interface WorkLogEntry { | ... | @@ -80,6 +80,8 @@ interface WorkLogEntry { |
| 80 | interface DerivedWorkLogEntry extends WorkLogEntry { | 80 | interface DerivedWorkLogEntry extends WorkLogEntry { |
| 81 | activityKind: OrchestrationThreadActivity["kind"]; | 81 | activityKind: OrchestrationThreadActivity["kind"]; |
| 82 | collapseKey?: string; | 82 | collapseKey?: string; |
| 83 | /** Grouping key for subagent lifecycle rows (one row per agent). */ | ||
| 84 | taskId?: string; | ||
| 83 | } | 85 | } |
| 84 | 86 | ||
| 85 | type RawThreadFeedEntry = | 87 | type RawThreadFeedEntry = |
| ... | @@ -235,6 +237,63 @@ function resolvePendingUserInputAnswer( | ... | @@ -235,6 +237,63 @@ function resolvePendingUserInputAnswer( |
| 235 | return normalizeDraftAnswer(draft?.selectedOptionLabel); | 237 | return normalizeDraftAnswer(draft?.selectedOptionLabel); |
| 236 | } | 238 | } |
| 237 | 239 | ||
| 240 | /** Codex children settle via task.updated (idle/failed/interrupted), never | ||
| 241 | * task.completed — these rows are mobile's only terminal signal for them. */ | ||
| 242 | const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet<string> = new Set([ | ||
| 243 | "idle", | ||
| 244 | "completed", | ||
| 245 | "failed", | ||
| 246 | "cancelled", | ||
| 247 | "interrupted", | ||
| 248 | ]); | ||
| 249 | |||
| 250 | function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean { | ||
| 251 | if (activity.kind !== "task.updated") { | ||
| 252 | return false; | ||
| 253 | } | ||
| 254 | const payload = | ||
| 255 | activity.payload && typeof activity.payload === "object" | ||
| 256 | ? (activity.payload as Record<string, unknown>) | ||
| 257 | : null; | ||
| 258 | return ( | ||
| 259 | payload?.timelineBypass === true && | ||
| 260 | typeof payload.status === "string" && | ||
| 261 | MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status) | ||
| 262 | ); | ||
| 263 | } | ||
| 264 | |||
| 265 | /** | ||
| 266 | * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal | ||
| 267 | * activity lives in the Agents sheet, not the work log. Terminal rows are | ||
| 268 | * kept — with no Agents surface on mobile they are the terminal signal | ||
| 269 | * (a surface that hides rows must keep its own terminal signal). That means | ||
| 270 | * task.completed (Claude) AND terminal bypassed task.updated (Codex, whose | ||
| 271 | * children never emit task.completed — review finding). | ||
| 272 | */ | ||
| 273 | function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { | ||
| 274 | const payload = | ||
| 275 | activity.payload && typeof activity.payload === "object" | ||
| 276 | ? (activity.payload as Record<string, unknown>) | ||
| 277 | : null; | ||
| 278 | if (!payload) { | ||
| 279 | return false; | ||
| 280 | } | ||
| 281 | const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalBypassUpdate(activity); | ||
| 282 | if (payload.timelineBypass === true && !isTerminalTaskRow) { | ||
| 283 | return true; | ||
| 284 | } | ||
| 285 | // agentId marks ownership, not "hide me": a NESTED AGENT's terminal row is | ||
| 286 | // the only signal mobile gets (no Agents sheet), so it stays. Only an | ||
| 287 | // agent's own background work (stamped "background") is internal — same | ||
| 288 | // rule as web (review finding: hiding on agentId alone dropped nested | ||
| 289 | // completions with no replacement UI). | ||
| 290 | const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0; | ||
| 291 | if (!ownedByAgent) { | ||
| 292 | return false; | ||
| 293 | } | ||
| 294 | return !(isTerminalTaskRow && payload.agentKind === "agent"); | ||
| 295 | } | ||
| 296 | |||
| 238 | function deriveWorkLogEntries( | 297 | function deriveWorkLogEntries( |
| 239 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 298 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 240 | ): DerivedWorkLogEntry[] { | 299 | ): DerivedWorkLogEntry[] { |
| ... | @@ -243,9 +302,13 @@ function deriveWorkLogEntries( | ... | @@ -243,9 +302,13 @@ function deriveWorkLogEntries( |
| 243 | for (const activity of ordered) { | 302 | for (const activity of ordered) { |
| 244 | if (activity.kind === "tool.started") continue; | 303 | if (activity.kind === "tool.started") continue; |
| 245 | if (activity.kind === "task.started") continue; | 304 | if (activity.kind === "task.started") continue; |
| 305 | // Terminal bypassed updates pass: Codex children's only terminal signal. | ||
| 306 | if (activity.kind === "task.updated" && !isTerminalBypassUpdate(activity)) continue; | ||
| 307 | if (activity.kind === "tool.progress") continue; | ||
| 246 | if (activity.kind === "context-window.updated") continue; | 308 | if (activity.kind === "context-window.updated") continue; |
| 247 | if (activity.summary === "Checkpoint captured") continue; | 309 | if (activity.summary === "Checkpoint captured") continue; |
| 248 | if (isPlanBoundaryToolActivity(activity)) continue; | 310 | if (isPlanBoundaryToolActivity(activity)) continue; |
| 311 | if (isAgentInternalActivity(activity)) continue; | ||
| 249 | entries.push(toDerivedWorkLogEntry(activity)); | 312 | entries.push(toDerivedWorkLogEntry(activity)); |
| 250 | } | 313 | } |
| 251 | return collapseDerivedWorkLogEntries(entries); | 314 | return collapseDerivedWorkLogEntries(entries); |
| ... | @@ -271,7 +334,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -271,7 +334,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 271 | const commandPreview = extractToolCommand(payload); | 334 | const commandPreview = extractToolCommand(payload); |
| 272 | const changedFiles = extractChangedFiles(payload); | 335 | const changedFiles = extractChangedFiles(payload); |
| 273 | const title = extractToolTitle(payload); | 336 | const title = extractToolTitle(payload); |
| 274 | const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; | 337 | // task.updated included: terminal bypassed updates (Codex children's only |
| 338 | // terminal signal) must carry task identity so they collapse per child | ||
| 339 | // instead of stacking anonymous "Task idle" rows. | ||
| 340 | const isTaskActivity = | ||
| 341 | activity.kind === "task.progress" || | ||
| 342 | activity.kind === "task.completed" || | ||
| 343 | activity.kind === "task.updated"; | ||
| 275 | const taskSummary = | 344 | const taskSummary = |
| 276 | isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 | 345 | isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 |
| 277 | ? payload.summary | 346 | ? payload.summary |
| ... | @@ -284,10 +353,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -284,10 +353,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 284 | ? payload.detail | 353 | ? payload.detail |
| 285 | : null; | 354 | : null; |
| 286 | const taskLabel = taskSummary || taskDetailAsLabel; | 355 | const taskLabel = taskSummary || taskDetailAsLabel; |
| 356 | const taskId = | ||
| 357 | isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0 | ||
| 358 | ? payload.taskId | ||
| 359 | : undefined; | ||
| 287 | const entry: DerivedWorkLogEntry = { | 360 | const entry: DerivedWorkLogEntry = { |
| 288 | id: activity.id, | 361 | id: activity.id, |
| 289 | createdAt: activity.createdAt, | 362 | createdAt: activity.createdAt, |
| 290 | turnId: activity.turnId, | 363 | turnId: activity.turnId, |
| 364 | ...(taskId ? { taskId } : {}), | ||
| 291 | label: taskLabel || activity.summary, | 365 | label: taskLabel || activity.summary, |
| 292 | tone: | 366 | tone: |
| 293 | activity.kind === "task.progress" | 367 | activity.kind === "task.progress" |
| ... | @@ -352,7 +426,25 @@ function collapseDerivedWorkLogEntries( | ... | @@ -352,7 +426,25 @@ function collapseDerivedWorkLogEntries( |
| 352 | entries: ReadonlyArray<DerivedWorkLogEntry>, | 426 | entries: ReadonlyArray<DerivedWorkLogEntry>, |
| 353 | ): DerivedWorkLogEntry[] { | 427 | ): DerivedWorkLogEntry[] { |
| 354 | const collapsed: DerivedWorkLogEntry[] = []; | 428 | const collapsed: DerivedWorkLogEntry[] = []; |
| 429 | // Subagent rows collapse by identity, not adjacency (quiet-timeline | ||
| 430 | // guarantee; mirrors web's session-logic). | ||
| 431 | const taskRowIndex = new Map<string, number>(); | ||
| 355 | for (const entry of entries) { | 432 | for (const entry of entries) { |
| 433 | const isTaskRow = | ||
| 434 | entry.taskId !== undefined && | ||
| 435 | (entry.activityKind === "task.progress" || | ||
| 436 | entry.activityKind === "task.completed" || | ||
| 437 | entry.activityKind === "task.updated"); | ||
| 438 | if (isTaskRow && entry.taskId !== undefined) { | ||
| 439 | const existingIndex = taskRowIndex.get(entry.taskId); | ||
| 440 | if (existingIndex !== undefined) { | ||
| 441 | collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry); | ||
| 442 | continue; | ||
| 443 | } | ||
| 444 | taskRowIndex.set(entry.taskId, collapsed.length); | ||
| 445 | collapsed.push(entry); | ||
| 446 | continue; | ||
| 447 | } | ||
| 356 | const previous = collapsed.at(-1); | 448 | const previous = collapsed.at(-1); |
| 357 | if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { | 449 | if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { |
| 358 | collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); | 450 | collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); |
apps/server/integration/OrchestrationEngineHarness.integration.ts+2-1| ... | @@ -50,6 +50,7 @@ import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityRe | ... | @@ -50,6 +50,7 @@ import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityRe |
| 50 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; | 50 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; |
| 51 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; | 51 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; |
| 52 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; | 52 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; |
| 53 | import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts"; | ||
| 53 | import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; | 54 | import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; |
| 54 | import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; | 55 | import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; |
| 55 | import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; | 56 | import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; |
| ... | @@ -305,7 +306,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -305,7 +306,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 305 | checkpointStoreLayer, | 306 | checkpointStoreLayer, |
| 306 | providerLayer, | 307 | providerLayer, |
| 307 | RuntimeReceiptBusTest, | 308 | RuntimeReceiptBusTest, |
| 308 | ); | 309 | ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer)); |
| 309 | const serverSettingsLayer = ServerSettingsService.layerTest(); | 310 | const serverSettingsLayer = ServerSettingsService.layerTest(); |
| 310 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | 311 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
| 311 | Layer.provideMerge(runtimeServicesLayer), | 312 | Layer.provideMerge(runtimeServicesLayer), |
apps/server/src/auth/RpcAuthorization.ts+1| ... | @@ -22,6 +22,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"]; | ... | @@ -22,6 +22,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"]; |
| 22 | */ | 22 | */ |
| 23 | export const RPC_REQUIRED_SCOPES = { | 23 | export const RPC_REQUIRED_SCOPES = { |
| 24 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, | 24 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, |
| 25 | [ORCHESTRATION_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, | ||
| 25 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, | 26 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, |
| 26 | [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, | 27 | [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, |
| 27 | [ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, | 28 | [ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, |
apps/server/src/orchestration/ActivityPayloadProjection.test.ts created+63| ... | @@ -0,0 +1,63 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import type { OrchestrationThreadActivity } from "@t3tools/contracts"; | ||
| 3 | import { projectActivityPayload } from "./ActivityPayloadProjection.ts"; | ||
| 4 | |||
| 5 | function activity(payload: Record<string, unknown>): OrchestrationThreadActivity { | ||
| 6 | return { | ||
| 7 | id: "activity-1", | ||
| 8 | tone: "tool", | ||
| 9 | kind: "tool.completed", | ||
| 10 | summary: "Tool", | ||
| 11 | payload, | ||
| 12 | turnId: null, | ||
| 13 | createdAt: "2026-08-01T10:00:00.000Z", | ||
| 14 | } as unknown as OrchestrationThreadActivity; | ||
| 15 | } | ||
| 16 | |||
| 17 | /** | ||
| 18 | * Wire-survival regression: the slimming pass rewrites payload.data but must | ||
| 19 | * never strip the top-level per-agent fields the subagent fold depends on. | ||
| 20 | * If slimming ever moves to an allowlist over the whole payload, these | ||
| 21 | * assertions are the tripwire. | ||
| 22 | */ | ||
| 23 | describe("projectActivityPayload agent-field survival", () => { | ||
| 24 | it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { | ||
| 25 | const projected = projectActivityPayload( | ||
| 26 | activity({ | ||
| 27 | itemType: "command_execution", | ||
| 28 | agentId: "task-123", | ||
| 29 | parentToolUseId: "toolu_abc", | ||
| 30 | data: { | ||
| 31 | toolName: "Bash", | ||
| 32 | input: { command: "ls" }, | ||
| 33 | command: "ls", | ||
| 34 | rawOutput: { content: "x".repeat(10) }, | ||
| 35 | somethingClientNeverReads: { big: "blob" }, | ||
| 36 | }, | ||
| 37 | }), | ||
| 38 | ); | ||
| 39 | const payload = projected.payload as Record<string, unknown>; | ||
| 40 | expect(payload.agentId).toBe("task-123"); | ||
| 41 | expect(payload.parentToolUseId).toBe("toolu_abc"); | ||
| 42 | // Slimming itself still applies to data. | ||
| 43 | const data = payload.data as Record<string, unknown>; | ||
| 44 | expect(data.somethingClientNeverReads).toBeUndefined(); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("passes task lifecycle payloads (no data field) through untouched", () => { | ||
| 48 | const source = activity({ | ||
| 49 | taskId: "task-9", | ||
| 50 | title: "Audit auth", | ||
| 51 | role: "explorer", | ||
| 52 | model: "opus", | ||
| 53 | effort: "high", | ||
| 54 | workflowName: "audit-flow", | ||
| 55 | phases: [{ index: 0, title: "Audit" }], | ||
| 56 | typedUsage: { totalTokens: 1200 }, | ||
| 57 | runHandles: { runId: "run-1", scriptPath: "/tmp/wf.js" }, | ||
| 58 | timelineBypass: true, | ||
| 59 | }); | ||
| 60 | const projected = projectActivityPayload(source); | ||
| 61 | expect(projected.payload).toEqual(source.payload); | ||
| 62 | }); | ||
| 63 | }); | ||
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+3| ... | @@ -39,6 +39,7 @@ import { CheckpointReactorLive } from "./CheckpointReactor.ts"; | ... | @@ -39,6 +39,7 @@ import { CheckpointReactorLive } from "./CheckpointReactor.ts"; |
| 39 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 39 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 40 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 40 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 41 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 41 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 42 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; | ||
| 42 | import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts"; | 43 | import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts"; |
| 43 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | 44 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 44 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | 45 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
| ... | @@ -294,6 +295,7 @@ describe("CheckpointReactor", () => { | ... | @@ -294,6 +295,7 @@ describe("CheckpointReactor", () => { |
| 294 | ); | 295 | ); |
| 295 | const orchestrationLayer = OrchestrationEngineLive.pipe( | 296 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 296 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), | 297 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| 298 | Layer.provide(ThreadBackgroundLiveness.layer), | ||
| 297 | Layer.provide(OrchestrationProjectionPipelineLive), | 299 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 298 | Layer.provide(OrchestrationEventStoreLive), | 300 | Layer.provide(OrchestrationEventStoreLive), |
| 299 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 301 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| ... | @@ -301,6 +303,7 @@ describe("CheckpointReactor", () => { | ... | @@ -301,6 +303,7 @@ describe("CheckpointReactor", () => { |
| 301 | Layer.provide(SqlitePersistenceMemory), | 303 | Layer.provide(SqlitePersistenceMemory), |
| 302 | ); | 304 | ); |
| 303 | const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( | 305 | const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( |
| 306 | Layer.provide(ThreadBackgroundLiveness.layer), | ||
| 304 | Layer.provide(RepositoryIdentityResolver.layer), | 307 | Layer.provide(RepositoryIdentityResolver.layer), |
| 305 | Layer.provide(SqlitePersistenceMemory), | 308 | Layer.provide(SqlitePersistenceMemory), |
| 306 | ); | 309 | ); |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5| ... | @@ -31,6 +31,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes | ... | @@ -31,6 +31,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes |
| 31 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 31 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 32 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 32 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 33 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 33 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 34 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; | ||
| 34 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 35 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 35 | import { | 36 | import { |
| 36 | OrchestrationProjectionPipeline, | 37 | OrchestrationProjectionPipeline, |
| ... | @@ -55,6 +56,7 @@ async function createOrchestrationSystem() { | ... | @@ -55,6 +56,7 @@ async function createOrchestrationSystem() { |
| 55 | ), | 56 | ), |
| 56 | OrchestrationProjectionSnapshotQueryLive, | 57 | OrchestrationProjectionSnapshotQueryLive, |
| 57 | ).pipe( | 58 | ).pipe( |
| 59 | Layer.provide(ThreadBackgroundLiveness.layer), | ||
| 58 | Layer.provide(OrchestrationEventStoreLive), | 60 | Layer.provide(OrchestrationEventStoreLive), |
| 59 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 61 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 60 | Layer.provide(RepositoryIdentityResolver.layer), | 62 | Layer.provide(RepositoryIdentityResolver.layer), |
| ... | @@ -817,6 +819,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -817,6 +819,7 @@ describe("OrchestrationEngine", () => { |
| 817 | const runtime = ManagedRuntime.make( | 819 | const runtime = ManagedRuntime.make( |
| 818 | OrchestrationEngineLive.pipe( | 820 | OrchestrationEngineLive.pipe( |
| 819 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), | 821 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| 822 | Layer.provide(ThreadBackgroundLiveness.layer), | ||
| 820 | Layer.provide(OrchestrationProjectionPipelineLive), | 823 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 821 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), | 824 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), |
| 822 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 825 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| ... | @@ -922,6 +925,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -922,6 +925,7 @@ describe("OrchestrationEngine", () => { |
| 922 | const runtime = ManagedRuntime.make( | 925 | const runtime = ManagedRuntime.make( |
| 923 | OrchestrationEngineLive.pipe( | 926 | OrchestrationEngineLive.pipe( |
| 924 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), | 927 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| 928 | Layer.provide(ThreadBackgroundLiveness.layer), | ||
| 925 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), | 929 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 926 | Layer.provide(OrchestrationEventStoreLive), | 930 | Layer.provide(OrchestrationEventStoreLive), |
| 927 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 931 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| ... | @@ -1065,6 +1069,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -1065,6 +1069,7 @@ describe("OrchestrationEngine", () => { |
| 1065 | const runtime = ManagedRuntime.make( | 1069 | const runtime = ManagedRuntime.make( |
| 1066 | OrchestrationEngineLive.pipe( | 1070 | OrchestrationEngineLive.pipe( |
| 1067 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), | 1071 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| 1072 | Layer.provide(ThreadBackgroundLiveness.layer), | ||
| 1068 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), | 1073 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 1069 | Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), | 1074 | Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), |
| 1070 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 1075 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2| ... | @@ -31,6 +31,7 @@ import { | ... | @@ -31,6 +31,7 @@ import { |
| 31 | OrchestrationProjectionPipelineLive, | 31 | OrchestrationProjectionPipelineLive, |
| 32 | } from "./ProjectionPipeline.ts"; | 32 | } from "./ProjectionPipeline.ts"; |
| 33 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 33 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 34 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; | ||
| 34 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 35 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 35 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; | 36 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; |
| 36 | import { ServerConfig } from "../../config.ts"; | 37 | import { ServerConfig } from "../../config.ts"; |
| ... | @@ -2664,6 +2665,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta | ... | @@ -2664,6 +2665,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta |
| 2664 | const engineLayer = it.layer( | 2665 | const engineLayer = it.layer( |
| 2665 | OrchestrationEngineLive.pipe( | 2666 | OrchestrationEngineLive.pipe( |
| 2666 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), | 2667 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| 2668 | Layer.provide(ThreadBackgroundLiveness.layer), | ||
| 2667 | Layer.provide(OrchestrationProjectionPipelineLive), | 2669 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 2668 | Layer.provide(OrchestrationEventStoreLive), | 2670 | Layer.provide(OrchestrationEventStoreLive), |
| 2669 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 2671 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+4| ... | @@ -17,6 +17,7 @@ import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | ... | @@ -17,6 +17,7 @@ import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 17 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; | 17 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| 18 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; | 18 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 19 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 19 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 20 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; | ||
| 20 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; | 21 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| 21 | 22 | ||
| 22 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); | 23 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); |
| ... | @@ -27,6 +28,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val | ... | @@ -27,6 +28,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val |
| 27 | 28 | ||
| 28 | const projectionSnapshotLayer = it.layer( | 29 | const projectionSnapshotLayer = it.layer( |
| 29 | OrchestrationProjectionSnapshotQueryLive.pipe( | 30 | OrchestrationProjectionSnapshotQueryLive.pipe( |
| 31 | Layer.provide(ThreadBackgroundLiveness.layer), | ||
| 30 | Layer.provideMerge(RepositoryIdentityResolver.layer), | 32 | Layer.provideMerge(RepositoryIdentityResolver.layer), |
| 31 | Layer.provideMerge(SqlitePersistenceMemory), | 33 | Layer.provideMerge(SqlitePersistenceMemory), |
| 32 | Layer.provideMerge(NodeServices.layer), | 34 | Layer.provideMerge(NodeServices.layer), |
| ... | @@ -443,6 +445,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -443,6 +445,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 443 | hasPendingApprovals: true, | 445 | hasPendingApprovals: true, |
| 444 | hasPendingUserInput: false, | 446 | hasPendingUserInput: false, |
| 445 | hasActionableProposedPlan: false, | 447 | hasActionableProposedPlan: false, |
| 448 | backgroundLiveness: null, | ||
| 446 | }, | 449 | }, |
| 447 | ]); | 450 | ]); |
| 448 | 451 | ||
| ... | @@ -1825,6 +1828,7 @@ it.effect( | ... | @@ -1825,6 +1828,7 @@ it.effect( |
| 1825 | () => { | 1828 | () => { |
| 1826 | const resolveCalls: string[] = []; | 1829 | const resolveCalls: string[] = []; |
| 1827 | const layer = OrchestrationProjectionSnapshotQueryLive.pipe( | 1830 | const layer = OrchestrationProjectionSnapshotQueryLive.pipe( |
| 1831 | Layer.provide(ThreadBackgroundLiveness.layer), | ||
| 1828 | Layer.provideMerge( | 1832 | Layer.provideMerge( |
| 1829 | Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { | 1833 | Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { |
| 1830 | resolve: (cwd: string) => | 1834 | resolve: (cwd: string) => |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+11| ... | @@ -43,6 +43,7 @@ import { | ... | @@ -43,6 +43,7 @@ import { |
| 43 | type ProjectionRepositoryError, | 43 | type ProjectionRepositoryError, |
| 44 | } from "../../persistence/Errors.ts"; | 44 | } from "../../persistence/Errors.ts"; |
| 45 | import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; | 45 | import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; |
| 46 | import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; | ||
| 46 | import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; | 47 | import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; |
| 47 | import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; | 48 | import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; |
| 48 | import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; | 49 | import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; |
| ... | @@ -308,6 +309,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st | ... | @@ -308,6 +309,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st |
| 308 | } | 309 | } |
| 309 | 310 | ||
| 310 | const makeProjectionSnapshotQuery = Effect.gen(function* () { | 311 | const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 312 | const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; | ||
| 311 | const sql = yield* SqlClient.SqlClient; | 313 | const sql = yield* SqlClient.SqlClient; |
| 312 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | 314 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; |
| 313 | const repositoryIdentityResolutionConcurrency = 4; | 315 | const repositoryIdentityResolutionConcurrency = 4; |
| ... | @@ -1678,6 +1680,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1678,6 +1680,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1678 | hasPendingApprovals: row.pendingApprovalCount > 0, | 1680 | hasPendingApprovals: row.pendingApprovalCount > 0, |
| 1679 | hasPendingUserInput: row.pendingUserInputCount > 0, | 1681 | hasPendingUserInput: row.pendingUserInputCount > 0, |
| 1680 | hasActionableProposedPlan: row.hasActionableProposedPlan > 0, | 1682 | hasActionableProposedPlan: row.hasActionableProposedPlan > 0, |
| 1683 | backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( | ||
| 1684 | row.threadId, | ||
| 1685 | ), | ||
| 1681 | } satisfies OrchestrationThreadShell) | 1686 | } satisfies OrchestrationThreadShell) |
| 1682 | : Result.failVoid, | 1687 | : Result.failVoid, |
| 1683 | ), | 1688 | ), |
| ... | @@ -1818,6 +1823,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1818,6 +1823,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1818 | hasPendingApprovals: row.pendingApprovalCount > 0, | 1823 | hasPendingApprovals: row.pendingApprovalCount > 0, |
| 1819 | hasPendingUserInput: row.pendingUserInputCount > 0, | 1824 | hasPendingUserInput: row.pendingUserInputCount > 0, |
| 1820 | hasActionableProposedPlan: row.hasActionableProposedPlan > 0, | 1825 | hasActionableProposedPlan: row.hasActionableProposedPlan > 0, |
| 1826 | backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( | ||
| 1827 | row.threadId, | ||
| 1828 | ), | ||
| 1821 | }), | 1829 | }), |
| 1822 | ), | 1830 | ), |
| 1823 | updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", | 1831 | updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", |
| ... | @@ -2090,6 +2098,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -2090,6 +2098,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2090 | hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, | 2098 | hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, |
| 2091 | hasPendingUserInput: threadRow.value.pendingUserInputCount > 0, | 2099 | hasPendingUserInput: threadRow.value.pendingUserInputCount > 0, |
| 2092 | hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0, | 2100 | hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0, |
| 2101 | backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( | ||
| 2102 | threadRow.value.threadId, | ||
| 2103 | ), | ||
| 2093 | } satisfies OrchestrationThreadShell); | 2104 | } satisfies OrchestrationThreadShell); |
| 2094 | }); | 2105 | }); |
| 2095 | 2106 |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3| ... | @@ -48,6 +48,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes | ... | @@ -48,6 +48,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes |
| 48 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 48 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 49 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 49 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 50 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 50 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 51 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; | ||
| 51 | import { | 52 | import { |
| 52 | providerErrorLabel, | 53 | providerErrorLabel, |
| 53 | providerErrorLabelFromInstanceHint, | 54 | providerErrorLabelFromInstanceHint, |
| ... | @@ -345,6 +346,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -345,6 +346,7 @@ describe("ProviderCommandReactor", () => { |
| 345 | 346 | ||
| 346 | const orchestrationLayer = OrchestrationEngineLive.pipe( | 347 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 347 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), | 348 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| 349 | Layer.provide(ThreadBackgroundLiveness.layer), | ||
| 348 | Layer.provide(OrchestrationProjectionPipelineLive), | 350 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 349 | Layer.provide(OrchestrationEventStoreLive), | 351 | Layer.provide(OrchestrationEventStoreLive), |
| 350 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 352 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| ... | @@ -352,6 +354,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -352,6 +354,7 @@ describe("ProviderCommandReactor", () => { |
| 352 | Layer.provide(SqlitePersistenceMemory), | 354 | Layer.provide(SqlitePersistenceMemory), |
| 353 | ); | 355 | ); |
| 354 | const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( | 356 | const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( |
| 357 | Layer.provide(ThreadBackgroundLiveness.layer), | ||
| 355 | Layer.provide(RepositoryIdentityResolver.layer), | 358 | Layer.provide(RepositoryIdentityResolver.layer), |
| 356 | Layer.provide(SqlitePersistenceMemory), | 359 | Layer.provide(SqlitePersistenceMemory), |
| 357 | ); | 360 | ); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+10-3| ... | @@ -44,6 +44,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes | ... | @@ -44,6 +44,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes |
| 44 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 44 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 45 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 45 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 46 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 46 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 47 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; | ||
| 47 | import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; | 48 | import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; |
| 48 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 49 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 49 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | 50 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| ... | @@ -237,6 +238,9 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -237,6 +238,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 237 | const layer = ProviderRuntimeIngestionLive.pipe( | 238 | const layer = ProviderRuntimeIngestionLive.pipe( |
| 238 | Layer.provideMerge(orchestrationLayer), | 239 | Layer.provideMerge(orchestrationLayer), |
| 239 | Layer.provideMerge(projectionSnapshotLayer), | 240 | Layer.provideMerge(projectionSnapshotLayer), |
| 241 | // Single shared liveness instance across ingestion (writer), the | ||
| 242 | // engine, and the snapshot query (reader). | ||
| 243 | Layer.provideMerge(ThreadBackgroundLiveness.layer), | ||
| 240 | Layer.provideMerge(SqlitePersistenceMemory), | 244 | Layer.provideMerge(SqlitePersistenceMemory), |
| 241 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), | 245 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 242 | Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), | 246 | Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), |
| ... | @@ -3143,7 +3147,8 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -3143,7 +3147,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 3143 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-started", | 3147 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-started", |
| 3144 | ); | 3148 | ); |
| 3145 | const progress = thread.activities.find( | 3149 | const progress = thread.activities.find( |
| 3146 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-progress", | 3150 | (activity: ProviderRuntimeTestActivity) => |
| 3151 | activity.id === "task-progress:thread-1:turn-task-1", | ||
| 3147 | ); | 3152 | ); |
| 3148 | const completed = thread.activities.find( | 3153 | const completed = thread.activities.find( |
| 3149 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-completed", | 3154 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-completed", |
| ... | @@ -3227,7 +3232,8 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -3227,7 +3232,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 3227 | ); | 3232 | ); |
| 3228 | 3233 | ||
| 3229 | const progress = thread.activities.find( | 3234 | const progress = thread.activities.find( |
| 3230 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-progress", | 3235 | (activity: ProviderRuntimeTestActivity) => |
| 3236 | activity.id === "task-progress:thread-1:named-task-1", | ||
| 3231 | ); | 3237 | ); |
| 3232 | const completed = thread.activities.find( | 3238 | const completed = thread.activities.find( |
| 3233 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed", | 3239 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed", |
| ... | @@ -3318,7 +3324,8 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -3318,7 +3324,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 3318 | 3324 | ||
| 3319 | await waitForThread(harness.readModel, (entry) => | 3325 | await waitForThread(harness.readModel, (entry) => |
| 3320 | entry.activities.some( | 3326 | entry.activities.some( |
| 3321 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-progress", | 3327 | (activity: ProviderRuntimeTestActivity) => |
| 3328 | activity.id === "task-progress:thread-1:swept-task-1", | ||
| 3322 | ), | 3329 | ), |
| 3323 | ); | 3330 | ); |
| 3324 | 3331 |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+176-1| ... | @@ -7,6 +7,8 @@ import { | ... | @@ -7,6 +7,8 @@ import { |
| 7 | type OrchestrationMessage, | 7 | type OrchestrationMessage, |
| 8 | type OrchestrationProposedPlanId, | 8 | type OrchestrationProposedPlanId, |
| 9 | CheckpointRef, | 9 | CheckpointRef, |
| 10 | classifyTaskAgentKind, | ||
| 11 | EventId, | ||
| 10 | isToolLifecycleItemType, | 12 | isToolLifecycleItemType, |
| 11 | ThreadId, | 13 | ThreadId, |
| 12 | type ThreadTokenUsageSnapshot, | 14 | type ThreadTokenUsageSnapshot, |
| ... | @@ -32,6 +34,7 @@ import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionT | ... | @@ -32,6 +34,7 @@ import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionT |
| 32 | import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; | 34 | import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; |
| 33 | import { isGitRepository } from "../../git/Utils.ts"; | 35 | import { isGitRepository } from "../../git/Utils.ts"; |
| 34 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 36 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 37 | import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; | ||
| 35 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; | 38 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| 36 | import { | 39 | import { |
| 37 | ProviderRuntimeIngestionService, | 40 | ProviderRuntimeIngestionService, |
| ... | @@ -307,6 +310,52 @@ function requestKindFromCanonicalRequestType( | ... | @@ -307,6 +310,52 @@ function requestKindFromCanonicalRequestType( |
| 307 | } | 310 | } |
| 308 | } | 311 | } |
| 309 | 312 | ||
| 313 | /** | ||
| 314 | * Copies the optional TaskAgentLinkage bundle from a task.* runtime payload | ||
| 315 | * into the persisted activity payload. Identity fields ride on every row so | ||
| 316 | * client folds survive activity retention; absent fields stay absent. | ||
| 317 | */ | ||
| 318 | function taskLinkageActivityFields(payload: Record<string, unknown>): Record<string, unknown> { | ||
| 319 | const fields: Record<string, unknown> = { | ||
| 320 | // Server-stamped classification: persisted rows are self-describing, so | ||
| 321 | // clients trust the stamp instead of re-deriving agent-vs-background | ||
| 322 | // from taskType denylists and marker heuristics (legacy rows without a | ||
| 323 | // stamp keep the client fallback). | ||
| 324 | agentKind: classifyTaskAgentKind({ | ||
| 325 | taskType: typeof payload.taskType === "string" ? payload.taskType : undefined, | ||
| 326 | agentId: typeof payload.agentId === "string" ? payload.agentId : undefined, | ||
| 327 | }), | ||
| 328 | }; | ||
| 329 | for (const key of [ | ||
| 330 | "taskType", | ||
| 331 | "agentId", | ||
| 332 | "title", | ||
| 333 | "role", | ||
| 334 | "model", | ||
| 335 | "effort", | ||
| 336 | "toolUseId", | ||
| 337 | "parentAgentId", | ||
| 338 | "workflowName", | ||
| 339 | "agentIndex", | ||
| 340 | "phaseIndex", | ||
| 341 | "phaseTitle", | ||
| 342 | "phases", | ||
| 343 | "attempt", | ||
| 344 | "runHandles", | ||
| 345 | "outputFile", | ||
| 346 | "agentPath", | ||
| 347 | "timelineBypass", | ||
| 348 | "typedUsage", | ||
| 349 | "status", | ||
| 350 | "error", | ||
| 351 | ] as const) { | ||
| 352 | if (payload[key] !== undefined) { | ||
| 353 | fields[key] = payload[key]; | ||
| 354 | } | ||
| 355 | } | ||
| 356 | return fields; | ||
| 357 | } | ||
| 358 | |||
| 310 | export function runtimeEventToActivities( | 359 | export function runtimeEventToActivities( |
| 311 | event: ProviderRuntimeEvent, | 360 | event: ProviderRuntimeEvent, |
| 312 | taskTitle?: string, | 361 | taskTitle?: string, |
| ... | @@ -505,6 +554,7 @@ export function runtimeEventToActivities( | ... | @@ -505,6 +554,7 @@ export function runtimeEventToActivities( |
| 505 | ...(event.payload.description | 554 | ...(event.payload.description |
| 506 | ? { detail: truncateDetail(event.payload.description) } | 555 | ? { detail: truncateDetail(event.payload.description) } |
| 507 | : {}), | 556 | : {}), |
| 557 | ...taskLinkageActivityFields(event.payload as Record<string, unknown>), | ||
| 508 | }, | 558 | }, |
| 509 | turnId: toTurnId(event.turnId) ?? null, | 559 | turnId: toTurnId(event.turnId) ?? null, |
| 510 | ...maybeSequence, | 560 | ...maybeSequence, |
| ... | @@ -515,7 +565,16 @@ export function runtimeEventToActivities( | ... | @@ -515,7 +565,16 @@ export function runtimeEventToActivities( |
| 515 | case "task.progress": { | 565 | case "task.progress": { |
| 516 | return [ | 566 | return [ |
| 517 | { | 567 | { |
| 518 | id: event.eventId, | 568 | // Stable per-task id: progress is "latest state", not history, so |
| 569 | // each tick REPLACES the last via the activity upsert (PK + the | ||
| 570 | // replace-by-id apply in projector and client reducer). Keeps one | ||
| 571 | // progress row per task instead of thousands, so a large fleet's | ||
| 572 | // ticks can no longer evict its own start/terminal rows out of | ||
| 573 | // the 500-row retention window. Thread-scoped: activity_id is a | ||
| 574 | // GLOBAL primary key and Claude task ids are session-local, so a | ||
| 575 | // bare taskId could collide across threads and steal another | ||
| 576 | // thread's row (review finding). | ||
| 577 | id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`), | ||
| 519 | createdAt: event.createdAt, | 578 | createdAt: event.createdAt, |
| 520 | tone: "info", | 579 | tone: "info", |
| 521 | kind: "task.progress", | 580 | kind: "task.progress", |
| ... | @@ -532,6 +591,71 @@ export function runtimeEventToActivities( | ... | @@ -532,6 +591,71 @@ export function runtimeEventToActivities( |
| 532 | ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), | 591 | ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), |
| 533 | ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), | 592 | ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), |
| 534 | ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), | 593 | ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), |
| 594 | ...taskLinkageActivityFields(event.payload as Record<string, unknown>), | ||
| 595 | }, | ||
| 596 | turnId: toTurnId(event.turnId) ?? null, | ||
| 597 | ...maybeSequence, | ||
| 598 | }, | ||
| 599 | ]; | ||
| 600 | } | ||
| 601 | |||
| 602 | case "task.updated": { | ||
| 603 | return [ | ||
| 604 | { | ||
| 605 | id: event.eventId, | ||
| 606 | createdAt: event.createdAt, | ||
| 607 | tone: event.payload.status === "failed" ? "error" : "info", | ||
| 608 | kind: "task.updated", | ||
| 609 | summary: | ||
| 610 | event.payload.status === "failed" | ||
| 611 | ? "Task failed" | ||
| 612 | : event.payload.status | ||
| 613 | ? `Task ${event.payload.status}` | ||
| 614 | : "Task updated", | ||
| 615 | payload: { | ||
| 616 | taskId: event.payload.taskId, | ||
| 617 | ...(event.payload.description | ||
| 618 | ? { detail: truncateDetail(event.payload.description) } | ||
| 619 | : {}), | ||
| 620 | ...(event.payload.endedAt ? { endedAt: event.payload.endedAt } : {}), | ||
| 621 | ...(event.payload.isBackgrounded !== undefined | ||
| 622 | ? { isBackgrounded: event.payload.isBackgrounded } | ||
| 623 | : {}), | ||
| 624 | ...taskLinkageActivityFields(event.payload as Record<string, unknown>), | ||
| 625 | }, | ||
| 626 | turnId: toTurnId(event.turnId) ?? null, | ||
| 627 | ...maybeSequence, | ||
| 628 | }, | ||
| 629 | ]; | ||
| 630 | } | ||
| 631 | |||
| 632 | case "tool.progress": { | ||
| 633 | // Only agent-owned heartbeats are persisted: they feed the owning | ||
| 634 | // agent's activity line. Parent-conversation tool progress stays | ||
| 635 | // ephemeral (item lifecycle already covers it). | ||
| 636 | if (event.payload.taskId === undefined) { | ||
| 637 | return []; | ||
| 638 | } | ||
| 639 | return [ | ||
| 640 | { | ||
| 641 | // Same stable-id treatment as task.progress: a heartbeat is | ||
| 642 | // "what is this agent doing right now", so one row per task | ||
| 643 | // (thread-scoped for the same global-PK collision reason). | ||
| 644 | id: EventId.make(`tool-progress:${event.threadId}:${event.payload.taskId}`), | ||
| 645 | createdAt: event.createdAt, | ||
| 646 | tone: "info", | ||
| 647 | kind: "tool.progress", | ||
| 648 | summary: event.payload.toolName ?? "Tool progress", | ||
| 649 | payload: { | ||
| 650 | taskId: event.payload.taskId, | ||
| 651 | ...(event.payload.toolName ? { toolName: event.payload.toolName } : {}), | ||
| 652 | ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}), | ||
| 653 | ...(event.payload.elapsedSeconds !== undefined | ||
| 654 | ? { elapsedSeconds: event.payload.elapsedSeconds } | ||
| 655 | : {}), | ||
| 656 | ...(event.payload.parentToolUseId | ||
| 657 | ? { parentToolUseId: event.payload.parentToolUseId } | ||
| 658 | : {}), | ||
| 535 | }, | 659 | }, |
| 536 | turnId: toTurnId(event.turnId) ?? null, | 660 | turnId: toTurnId(event.turnId) ?? null, |
| 537 | ...maybeSequence, | 661 | ...maybeSequence, |
| ... | @@ -565,6 +689,7 @@ export function runtimeEventToActivities( | ... | @@ -565,6 +689,7 @@ export function runtimeEventToActivities( |
| 565 | } | 689 | } |
| 566 | : {}), | 690 | : {}), |
| 567 | ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), | 691 | ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), |
| 692 | ...taskLinkageActivityFields(event.payload as Record<string, unknown>), | ||
| 568 | }, | 693 | }, |
| 569 | turnId: toTurnId(event.turnId) ?? null, | 694 | turnId: toTurnId(event.turnId) ?? null, |
| 570 | ...maybeSequence, | 695 | ...maybeSequence, |
| ... | @@ -630,6 +755,10 @@ export function runtimeEventToActivities( | ... | @@ -630,6 +755,10 @@ export function runtimeEventToActivities( |
| 630 | ...(event.payload.status ? { status: event.payload.status } : {}), | 755 | ...(event.payload.status ? { status: event.payload.status } : {}), |
| 631 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), | 756 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 632 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), | 757 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), |
| 758 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), | ||
| 759 | ...(event.payload.parentToolUseId | ||
| 760 | ? { parentToolUseId: event.payload.parentToolUseId } | ||
| 761 | : {}), | ||
| 633 | }, | 762 | }, |
| 634 | turnId: toTurnId(event.turnId) ?? null, | 763 | turnId: toTurnId(event.turnId) ?? null, |
| 635 | ...maybeSequence, | 764 | ...maybeSequence, |
| ... | @@ -652,6 +781,10 @@ export function runtimeEventToActivities( | ... | @@ -652,6 +781,10 @@ export function runtimeEventToActivities( |
| 652 | itemType: event.payload.itemType, | 781 | itemType: event.payload.itemType, |
| 653 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), | 782 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 654 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), | 783 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), |
| 784 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), | ||
| 785 | ...(event.payload.parentToolUseId | ||
| 786 | ? { parentToolUseId: event.payload.parentToolUseId } | ||
| 787 | : {}), | ||
| 655 | }, | 788 | }, |
| 656 | turnId: toTurnId(event.turnId) ?? null, | 789 | turnId: toTurnId(event.turnId) ?? null, |
| 657 | ...maybeSequence, | 790 | ...maybeSequence, |
| ... | @@ -673,6 +806,10 @@ export function runtimeEventToActivities( | ... | @@ -673,6 +806,10 @@ export function runtimeEventToActivities( |
| 673 | payload: { | 806 | payload: { |
| 674 | itemType: event.payload.itemType, | 807 | itemType: event.payload.itemType, |
| 675 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), | 808 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 809 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), | ||
| 810 | ...(event.payload.parentToolUseId | ||
| 811 | ? { parentToolUseId: event.payload.parentToolUseId } | ||
| 812 | : {}), | ||
| 676 | }, | 813 | }, |
| 677 | turnId: toTurnId(event.turnId) ?? null, | 814 | turnId: toTurnId(event.turnId) ?? null, |
| 678 | ...maybeSequence, | 815 | ...maybeSequence, |
| ... | @@ -688,6 +825,7 @@ export function runtimeEventToActivities( | ... | @@ -688,6 +825,7 @@ export function runtimeEventToActivities( |
| 688 | } | 825 | } |
| 689 | 826 | ||
| 690 | const make = Effect.gen(function* () { | 827 | const make = Effect.gen(function* () { |
| 828 | const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; | ||
| 691 | const crypto = yield* Crypto.Crypto; | 829 | const crypto = yield* Crypto.Crypto; |
| 692 | const orchestrationEngine = yield* OrchestrationEngineService; | 830 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 693 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | 831 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| ... | @@ -1756,6 +1894,43 @@ const make = Effect.gen(function* () { | ... | @@ -1756,6 +1894,43 @@ const make = Effect.gen(function* () { |
| 1756 | yield* rememberTaskDescription(thread.id, event.payload.taskId, description); | 1894 | yield* rememberTaskDescription(thread.id, event.payload.taskId, description); |
| 1757 | } | 1895 | } |
| 1758 | } | 1896 | } |
| 1897 | // Sidebar background liveness: fed from the same lifecycle stream, | ||
| 1898 | // read by the shell query at mapping time (no persistence). | ||
| 1899 | switch (event.type) { | ||
| 1900 | case "task.started": | ||
| 1901 | case "task.progress": | ||
| 1902 | case "task.updated": | ||
| 1903 | case "task.completed": { | ||
| 1904 | const payload = event.payload as { | ||
| 1905 | taskId: string; | ||
| 1906 | taskType?: string; | ||
| 1907 | status?: string; | ||
| 1908 | agentId?: string; | ||
| 1909 | }; | ||
| 1910 | threadBackgroundLiveness.recordTaskLiveness({ | ||
| 1911 | threadId: thread.id, | ||
| 1912 | taskId: payload.taskId, | ||
| 1913 | taskType: payload.taskType, | ||
| 1914 | status: payload.status, | ||
| 1915 | agentId: payload.agentId, | ||
| 1916 | kind: | ||
| 1917 | event.type === "task.started" | ||
| 1918 | ? "started" | ||
| 1919 | : event.type === "task.progress" | ||
| 1920 | ? "progress" | ||
| 1921 | : event.type === "task.updated" | ||
| 1922 | ? "updated" | ||
| 1923 | : "completed", | ||
| 1924 | }); | ||
| 1925 | break; | ||
| 1926 | } | ||
| 1927 | case "session.exited": | ||
| 1928 | threadBackgroundLiveness.clearThreadLiveness(thread.id); | ||
| 1929 | break; | ||
| 1930 | default: | ||
| 1931 | break; | ||
| 1932 | } | ||
| 1933 | |||
| 1759 | let taskTitle: string | undefined; | 1934 | let taskTitle: string | undefined; |
| 1760 | if (event.type === "task.completed") { | 1935 | if (event.type === "task.completed") { |
| 1761 | taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); | 1936 | taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); |
apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts created+173| ... | @@ -0,0 +1,173 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; | ||
| 3 | |||
| 4 | describe("ThreadBackgroundLiveness", () => { | ||
| 5 | it("agents present as working; monitors as monitoring; agents win", () => { | ||
| 6 | const liveness = ThreadBackgroundLiveness.make(); | ||
| 7 | const threadId = "t-live-1"; | ||
| 8 | liveness.recordTaskLiveness({ | ||
| 9 | threadId, | ||
| 10 | taskId: "m1", | ||
| 11 | taskType: "local_bash", | ||
| 12 | status: undefined, | ||
| 13 | kind: "started", | ||
| 14 | }); | ||
| 15 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("monitoring"); | ||
| 16 | liveness.recordTaskLiveness({ | ||
| 17 | threadId, | ||
| 18 | taskId: "a1", | ||
| 19 | taskType: "subagent", | ||
| 20 | status: undefined, | ||
| 21 | kind: "started", | ||
| 22 | }); | ||
| 23 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working"); | ||
| 24 | liveness.recordTaskLiveness({ | ||
| 25 | threadId, | ||
| 26 | taskId: "a1", | ||
| 27 | taskType: "subagent", | ||
| 28 | status: "completed", | ||
| 29 | kind: "completed", | ||
| 30 | }); | ||
| 31 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("monitoring"); | ||
| 32 | liveness.recordTaskLiveness({ | ||
| 33 | threadId, | ||
| 34 | taskId: "m1", | ||
| 35 | taskType: "local_bash", | ||
| 36 | status: "completed", | ||
| 37 | kind: "completed", | ||
| 38 | }); | ||
| 39 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("terminal rows without a taskType still clear monitor entries", () => { | ||
| 43 | const liveness = ThreadBackgroundLiveness.make(); | ||
| 44 | const threadId = "t-live-2"; | ||
| 45 | liveness.recordTaskLiveness({ | ||
| 46 | threadId, | ||
| 47 | taskId: "m1", | ||
| 48 | taskType: "local_bash", | ||
| 49 | status: undefined, | ||
| 50 | kind: "started", | ||
| 51 | }); | ||
| 52 | // Terminal tick arrives with no taskType (common on task.completed). | ||
| 53 | liveness.recordTaskLiveness({ | ||
| 54 | threadId, | ||
| 55 | taskId: "m1", | ||
| 56 | taskType: undefined, | ||
| 57 | status: "completed", | ||
| 58 | kind: "completed", | ||
| 59 | }); | ||
| 60 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("nested agents (agentId + agent taskType) still count toward liveness", () => { | ||
| 64 | const liveness = ThreadBackgroundLiveness.make(); | ||
| 65 | const threadId = "t-live-nested"; | ||
| 66 | liveness.recordTaskLiveness({ | ||
| 67 | threadId, | ||
| 68 | taskId: "n1", | ||
| 69 | taskType: "local_agent", | ||
| 70 | status: undefined, | ||
| 71 | kind: "started", | ||
| 72 | agentId: "owner", | ||
| 73 | }); | ||
| 74 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working"); | ||
| 75 | liveness.recordTaskLiveness({ | ||
| 76 | threadId, | ||
| 77 | taskId: "n1", | ||
| 78 | taskType: "local_agent", | ||
| 79 | status: "completed", | ||
| 80 | kind: "completed", | ||
| 81 | agentId: "owner", | ||
| 82 | }); | ||
| 83 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("untyped rows count as agents; idle is not live; agent-owned tasks are ignored", () => { | ||
| 87 | const liveness = ThreadBackgroundLiveness.make(); | ||
| 88 | const threadId = "t-live-3"; | ||
| 89 | liveness.recordTaskLiveness({ | ||
| 90 | threadId, | ||
| 91 | taskId: "wf:1", | ||
| 92 | taskType: undefined, | ||
| 93 | status: "running", | ||
| 94 | kind: "progress", | ||
| 95 | }); | ||
| 96 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working"); | ||
| 97 | liveness.recordTaskLiveness({ | ||
| 98 | threadId, | ||
| 99 | taskId: "wf:1", | ||
| 100 | taskType: undefined, | ||
| 101 | status: "idle", | ||
| 102 | kind: "updated", | ||
| 103 | }); | ||
| 104 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); | ||
| 105 | liveness.recordTaskLiveness({ | ||
| 106 | threadId, | ||
| 107 | taskId: "sh:1", | ||
| 108 | taskType: "local_bash", | ||
| 109 | status: undefined, | ||
| 110 | kind: "started", | ||
| 111 | agentId: "owner", | ||
| 112 | }); | ||
| 113 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it("reclassification moves a task between buckets instead of duplicating it", () => { | ||
| 117 | const liveness = ThreadBackgroundLiveness.make(); | ||
| 118 | const threadId = "t-live-reclass"; | ||
| 119 | // First seen without a taskType: counts as an agent. | ||
| 120 | liveness.recordTaskLiveness({ | ||
| 121 | threadId, | ||
| 122 | taskId: "x1", | ||
| 123 | taskType: undefined, | ||
| 124 | status: "running", | ||
| 125 | kind: "started", | ||
| 126 | }); | ||
| 127 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working"); | ||
| 128 | // Later transition reveals it's a shell: downgrade to monitoring, not | ||
| 129 | // a stale duplicate pinning "working". | ||
| 130 | liveness.recordTaskLiveness({ | ||
| 131 | threadId, | ||
| 132 | taskId: "x1", | ||
| 133 | taskType: "local_bash", | ||
| 134 | status: "running", | ||
| 135 | kind: "progress", | ||
| 136 | }); | ||
| 137 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("monitoring"); | ||
| 138 | // Turning out to be inert or agent-owned drops the prior entry too. | ||
| 139 | liveness.recordTaskLiveness({ | ||
| 140 | threadId, | ||
| 141 | taskId: "x1", | ||
| 142 | taskType: "local_bash", | ||
| 143 | status: "running", | ||
| 144 | kind: "progress", | ||
| 145 | agentId: "owner", | ||
| 146 | }); | ||
| 147 | expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("plan tasks are inert; clear removes everything; instances are isolated", () => { | ||
| 151 | const a = ThreadBackgroundLiveness.make(); | ||
| 152 | const b = ThreadBackgroundLiveness.make(); | ||
| 153 | a.recordTaskLiveness({ | ||
| 154 | threadId: "t", | ||
| 155 | taskId: "p1", | ||
| 156 | taskType: "plan", | ||
| 157 | status: undefined, | ||
| 158 | kind: "started", | ||
| 159 | }); | ||
| 160 | expect(a.getThreadBackgroundLiveness("t")).toBeNull(); | ||
| 161 | a.recordTaskLiveness({ | ||
| 162 | threadId: "t", | ||
| 163 | taskId: "a1", | ||
| 164 | taskType: "local_workflow", | ||
| 165 | status: undefined, | ||
| 166 | kind: "started", | ||
| 167 | }); | ||
| 168 | expect(a.getThreadBackgroundLiveness("t")).toBe("working"); | ||
| 169 | expect(b.getThreadBackgroundLiveness("t")).toBeNull(); | ||
| 170 | a.clearThreadLiveness("t"); | ||
| 171 | expect(a.getThreadBackgroundLiveness("t")).toBeNull(); | ||
| 172 | }); | ||
| 173 | }); | ||
apps/server/src/orchestration/ThreadBackgroundLiveness.ts created+160| ... | @@ -0,0 +1,160 @@ | ||
| 1 | /** | ||
| 2 | * ThreadBackgroundLivenessService - in-memory per-thread background liveness | ||
| 3 | * for the sidebar status pill. | ||
| 4 | * | ||
| 5 | * The turn can settle while native background work runs on (subagent fleets, | ||
| 6 | * workflow runs, Monitor watch loops); the shell previously showed nothing. | ||
| 7 | * Ingestion records task lifecycle transitions and the shell query reads the | ||
| 8 | * derived state at mapping time — no persistence, no migration. After a | ||
| 9 | * server restart the registry is empty until new task events arrive, which | ||
| 10 | * matches reality: orphaned background work is not live. | ||
| 11 | * | ||
| 12 | * "monitoring" is reserved for watch loops (monitor tasks and background | ||
| 13 | * shells) when they are the ONLY live work; any agent work presents as | ||
| 14 | * "working". | ||
| 15 | * | ||
| 16 | * @module ThreadBackgroundLivenessService | ||
| 17 | */ | ||
| 18 | import { INERT_TASK_TYPES, MONITOR_TASK_TYPES } from "@t3tools/contracts"; | ||
| 19 | import * as Context from "effect/Context"; | ||
| 20 | import * as Effect from "effect/Effect"; | ||
| 21 | import * as Layer from "effect/Layer"; | ||
| 22 | |||
| 23 | export type ThreadBackgroundLiveness = "working" | "monitoring" | null; | ||
| 24 | |||
| 25 | interface ThreadLivenessState { | ||
| 26 | readonly agents: Set<string>; | ||
| 27 | readonly monitors: Set<string>; | ||
| 28 | } | ||
| 29 | |||
| 30 | // Classification sets are the shared contracts copies (MONITOR_TASK_TYPES: | ||
| 31 | // watch loops — monitor tasks plus background shells, which in practice are | ||
| 32 | // PR babysitting/log tails since pacing sleeps complete inside the turn; | ||
| 33 | // INERT_TASK_TYPES: plan-mode bookkeeping) so this registry, ingestion's | ||
| 34 | // agentKind stamp, and the client fold can never drift apart. | ||
| 35 | |||
| 36 | const TERMINAL_STATUSES: ReadonlySet<string> = new Set([ | ||
| 37 | "completed", | ||
| 38 | "failed", | ||
| 39 | "stopped", | ||
| 40 | "cancelled", | ||
| 41 | "interrupted", | ||
| 42 | ]); | ||
| 43 | |||
| 44 | export class ThreadBackgroundLivenessService extends Context.Service< | ||
| 45 | ThreadBackgroundLivenessService, | ||
| 46 | { | ||
| 47 | /** | ||
| 48 | * Feed one task lifecycle transition. taskType may be absent on | ||
| 49 | * synthesized rows (workflow members, Codex children) — those count as | ||
| 50 | * agents. agentId marks a task launched from inside a subagent: its | ||
| 51 | * internal shells are covered by the owning agent's liveness, but a | ||
| 52 | * NESTED AGENT (agentId + agent-flavored taskType) still counts — it | ||
| 53 | * can outlive its parent and must keep the thread Working. | ||
| 54 | */ | ||
| 55 | readonly recordTaskLiveness: (input: { | ||
| 56 | readonly threadId: string; | ||
| 57 | readonly taskId: string; | ||
| 58 | readonly taskType: string | undefined; | ||
| 59 | readonly status: string | undefined; | ||
| 60 | readonly kind: "started" | "progress" | "updated" | "completed"; | ||
| 61 | readonly agentId?: string | undefined; | ||
| 62 | }) => void; | ||
| 63 | |||
| 64 | /** Session death orphans all of a thread's background work. */ | ||
| 65 | readonly clearThreadLiveness: (threadId: string) => void; | ||
| 66 | |||
| 67 | /** | ||
| 68 | * Two-state vocabulary by design: any live agent work is "working"; | ||
| 69 | * "monitoring" only when watch loops are the ONLY live work. | ||
| 70 | */ | ||
| 71 | readonly getThreadBackgroundLiveness: (threadId: string) => ThreadBackgroundLiveness; | ||
| 72 | } | ||
| 73 | >()("t3/orchestration/ThreadBackgroundLiveness/ThreadBackgroundLivenessService") {} | ||
| 74 | |||
| 75 | export function make(): ThreadBackgroundLivenessService["Service"] { | ||
| 76 | const stateByThreadId = new Map<string, ThreadLivenessState>(); | ||
| 77 | |||
| 78 | const stateFor = (threadId: string): ThreadLivenessState => { | ||
| 79 | const existing = stateByThreadId.get(threadId); | ||
| 80 | if (existing) { | ||
| 81 | return existing; | ||
| 82 | } | ||
| 83 | const created: ThreadLivenessState = { agents: new Set(), monitors: new Set() }; | ||
| 84 | stateByThreadId.set(threadId, created); | ||
| 85 | return created; | ||
| 86 | }; | ||
| 87 | |||
| 88 | // Classification is per-transition, not sticky: a task first seen without | ||
| 89 | // a taskType may later reveal itself as a shell, become inert, or turn out | ||
| 90 | // to be agent-owned. Every path drops any prior entry for the taskId so a | ||
| 91 | // stale bucket assignment can't pin the thread's status (review finding). | ||
| 92 | const drop = (threadId: string, taskId: string) => { | ||
| 93 | const state = stateByThreadId.get(threadId); | ||
| 94 | if (!state) { | ||
| 95 | return; | ||
| 96 | } | ||
| 97 | state.agents.delete(taskId); | ||
| 98 | state.monitors.delete(taskId); | ||
| 99 | if (state.agents.size === 0 && state.monitors.size === 0) { | ||
| 100 | stateByThreadId.delete(threadId); | ||
| 101 | } | ||
| 102 | }; | ||
| 103 | |||
| 104 | return { | ||
| 105 | recordTaskLiveness: (input) => { | ||
| 106 | const taskType = input.taskType; | ||
| 107 | if (taskType !== undefined && INERT_TASK_TYPES.has(taskType)) { | ||
| 108 | drop(input.threadId, input.taskId); | ||
| 109 | return; | ||
| 110 | } | ||
| 111 | // A subagent's internal non-agent work (its own shells/monitors) is | ||
| 112 | // covered by the owning agent's liveness. Nested agents fall through: | ||
| 113 | // they can outlive their parent (review finding). | ||
| 114 | if ( | ||
| 115 | input.agentId !== undefined && | ||
| 116 | (taskType === undefined || MONITOR_TASK_TYPES.has(taskType)) | ||
| 117 | ) { | ||
| 118 | drop(input.threadId, input.taskId); | ||
| 119 | return; | ||
| 120 | } | ||
| 121 | |||
| 122 | // Idle counts as not-live: a resting (resumable) Codex child isn't | ||
| 123 | // doing anything, and an all-idle fleet must not pin Working. | ||
| 124 | const terminal = | ||
| 125 | input.kind === "completed" || | ||
| 126 | input.status === "idle" || | ||
| 127 | (input.status !== undefined && TERMINAL_STATUSES.has(input.status)); | ||
| 128 | if (terminal) { | ||
| 129 | drop(input.threadId, input.taskId); | ||
| 130 | return; | ||
| 131 | } | ||
| 132 | |||
| 133 | drop(input.threadId, input.taskId); | ||
| 134 | const state = stateFor(input.threadId); | ||
| 135 | const bucket = | ||
| 136 | taskType !== undefined && MONITOR_TASK_TYPES.has(taskType) ? state.monitors : state.agents; | ||
| 137 | bucket.add(input.taskId); | ||
| 138 | }, | ||
| 139 | |||
| 140 | clearThreadLiveness: (threadId) => { | ||
| 141 | stateByThreadId.delete(threadId); | ||
| 142 | }, | ||
| 143 | |||
| 144 | getThreadBackgroundLiveness: (threadId) => { | ||
| 145 | const state = stateByThreadId.get(threadId); | ||
| 146 | if (!state) { | ||
| 147 | return null; | ||
| 148 | } | ||
| 149 | if (state.agents.size > 0) { | ||
| 150 | return "working"; | ||
| 151 | } | ||
| 152 | if (state.monitors.size > 0) { | ||
| 153 | return "monitoring"; | ||
| 154 | } | ||
| 155 | return null; | ||
| 156 | }, | ||
| 157 | }; | ||
| 158 | } | ||
| 159 | |||
| 160 | export const layer = Layer.effect(ThreadBackgroundLivenessService, Effect.sync(make)); | ||
apps/server/src/orchestration/runtimeLayer.ts+5-1| ... | @@ -5,6 +5,7 @@ import { OrchestrationEventStoreLive } from "../persistence/Layers/Orchestration | ... | @@ -5,6 +5,7 @@ import { OrchestrationEventStoreLive } from "../persistence/Layers/Orchestration |
| 5 | import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; | 5 | import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; |
| 6 | import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; | 6 | import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; |
| 7 | import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; | 7 | import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; |
| 8 | import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; | ||
| 8 | 9 | ||
| 9 | export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( | 10 | export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( |
| 10 | OrchestrationEventStoreLive, | 11 | OrchestrationEventStoreLive, |
| ... | @@ -19,7 +20,10 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( | ... | @@ -19,7 +20,10 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( |
| 19 | OrchestrationProjectionSnapshotQueryLive, | 20 | OrchestrationProjectionSnapshotQueryLive, |
| 20 | OrchestrationEventInfrastructureLayerLive, | 21 | OrchestrationEventInfrastructureLayerLive, |
| 21 | OrchestrationProjectionPipelineLayerLive, | 22 | OrchestrationProjectionPipelineLayerLive, |
| 22 | ); | 23 | // Shared background-liveness registry: written by runtime ingestion, |
| 24 | // read by the snapshot query. provideMerge feeds the same instance to | ||
| 25 | // the snapshot query here and re-exports it for runtime ingestion. | ||
| 26 | ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer)); | ||
| 23 | 27 | ||
| 24 | export const OrchestrationLayerLive = Layer.mergeAll( | 28 | export const OrchestrationLayerLive = Layer.mergeAll( |
| 25 | OrchestrationInfrastructureLayerLive, | 29 | OrchestrationInfrastructureLayerLive, |
apps/server/src/orchestration/workflowScriptQuery.test.ts created+75| ... | @@ -0,0 +1,75 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodeOS from "node:os"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | import { it as effectIt } from "@effect/vitest"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import { afterAll, assert, describe } from "vite-plus/test"; | ||
| 8 | import { readWorkflowScript } from "./workflowScriptQuery.ts"; | ||
| 9 | |||
| 10 | const root = NodePath.join(NodeOS.homedir(), ".claude", "projects", "__wf_script_test__"); | ||
| 11 | NodeFS.mkdirSync(root, { recursive: true }); | ||
| 12 | const scriptPath = NodePath.join(root, "run.js"); | ||
| 13 | NodeFS.writeFileSync(scriptPath, "export const meta = {};\n"); | ||
| 14 | const outside = NodePath.join(NodeOS.tmpdir(), "wf-outside.js"); | ||
| 15 | NodeFS.writeFileSync(outside, "evil\n"); | ||
| 16 | const link = NodePath.join(root, "sneaky.js"); | ||
| 17 | try { | ||
| 18 | NodeFS.symlinkSync(outside, link); | ||
| 19 | } catch (error) { | ||
| 20 | // Tolerate only "already exists" from a prior run — any other failure | ||
| 21 | // (EPERM etc.) must fail setup, or the escape test below would pass | ||
| 22 | // vacuously on "not-found" without testing containment. | ||
| 23 | if ((error as NodeJS.ErrnoException).code !== "EEXIST") { | ||
| 24 | throw error; | ||
| 25 | } | ||
| 26 | } | ||
| 27 | if (!NodeFS.lstatSync(link).isSymbolicLink()) { | ||
| 28 | throw new Error("test setup: sneaky.js must be a symlink"); | ||
| 29 | } | ||
| 30 | |||
| 31 | afterAll(() => { | ||
| 32 | NodeFS.rmSync(root, { recursive: true, force: true }); | ||
| 33 | NodeFS.rmSync(outside, { force: true }); | ||
| 34 | }); | ||
| 35 | |||
| 36 | describe("readWorkflowScript containment", () => { | ||
| 37 | effectIt.effect("serves a real script under the projects root", () => | ||
| 38 | Effect.gen(function* () { | ||
| 39 | const result = yield* readWorkflowScript({ scriptPath }); | ||
| 40 | assert.include(result.contents, "export const meta"); | ||
| 41 | assert.equal(result.truncated, false); | ||
| 42 | }), | ||
| 43 | ); | ||
| 44 | |||
| 45 | effectIt.effect("rejects relative and non-js paths", () => | ||
| 46 | Effect.gen(function* () { | ||
| 47 | const relative = yield* Effect.exit(readWorkflowScript({ scriptPath: "run.js" })); | ||
| 48 | assert.equal(relative._tag, "Failure"); | ||
| 49 | const nonJs = yield* Effect.exit( | ||
| 50 | readWorkflowScript({ scriptPath: scriptPath.replace(".js", ".ts") }), | ||
| 51 | ); | ||
| 52 | assert.equal(nonJs._tag, "Failure"); | ||
| 53 | }), | ||
| 54 | ); | ||
| 55 | |||
| 56 | effectIt.effect("rejects paths outside the root and symlink escapes", () => | ||
| 57 | Effect.gen(function* () { | ||
| 58 | const escaped = yield* Effect.exit(readWorkflowScript({ scriptPath: outside })); | ||
| 59 | assert.equal(escaped._tag, "Failure"); | ||
| 60 | // A symlink INSIDE the root pointing outside must fail specifically on | ||
| 61 | // realpath re-containment — a "not-found" would mean the link was | ||
| 62 | // never exercised and the assertion proves nothing. | ||
| 63 | const sneaky = yield* Effect.exit( | ||
| 64 | readWorkflowScript({ scriptPath: link }).pipe( | ||
| 65 | Effect.flip, | ||
| 66 | Effect.map((error) => error.reason), | ||
| 67 | ), | ||
| 68 | ); | ||
| 69 | assert.equal(sneaky._tag, "Success"); | ||
| 70 | if (sneaky._tag === "Success") { | ||
| 71 | assert.equal(sneaky.value, "outside-root"); | ||
| 72 | } | ||
| 73 | }), | ||
| 74 | ); | ||
| 75 | }); | ||
apps/server/src/orchestration/workflowScriptQuery.ts created+124| ... | @@ -0,0 +1,124 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | /** | ||
| 3 | * Read-only access to persisted workflow scripts for the Agents surface's | ||
| 4 | * "{} script" affordance. | ||
| 5 | * | ||
| 6 | * Containment rules (lifted from the reviewed #3650 inspection service): | ||
| 7 | * - the resolved realpath must live under ~/.claude/projects (where the | ||
| 8 | * Claude harness persists workflow scripts) — realpath re-containment | ||
| 9 | * defeats symlink escapes, including a symlinked leaf file; | ||
| 10 | * - only .js leaf files are served; | ||
| 11 | * - reads are size-capped rather than failed, with a truncation marker. | ||
| 12 | * | ||
| 13 | * The client-supplied path is a hint from the workflow's runHandles; it is | ||
| 14 | * never trusted beyond these checks. | ||
| 15 | */ | ||
| 16 | import * as NodeFSP from "node:fs/promises"; | ||
| 17 | import * as NodeOS from "node:os"; | ||
| 18 | import * as NodePath from "node:path"; | ||
| 19 | |||
| 20 | import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts"; | ||
| 21 | import * as Effect from "effect/Effect"; | ||
| 22 | |||
| 23 | const SCRIPT_BYTE_CAP = 256 * 1024; | ||
| 24 | |||
| 25 | function scriptsRoot(): string { | ||
| 26 | return NodePath.join(NodeOS.homedir(), ".claude", "projects"); | ||
| 27 | } | ||
| 28 | |||
| 29 | export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: { | ||
| 30 | readonly scriptPath: string; | ||
| 31 | }) { | ||
| 32 | const requested = input.scriptPath; | ||
| 33 | |||
| 34 | if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") { | ||
| 35 | return yield* Effect.fail( | ||
| 36 | new OrchestrationGetWorkflowScriptError({ reason: "invalid-path", scriptPath: requested }), | ||
| 37 | ); | ||
| 38 | } | ||
| 39 | |||
| 40 | const root = yield* Effect.tryPromise({ | ||
| 41 | try: () => NodeFSP.realpath(scriptsRoot()), | ||
| 42 | catch: (cause) => | ||
| 43 | new OrchestrationGetWorkflowScriptError({ | ||
| 44 | reason: "root-unavailable", | ||
| 45 | scriptPath: requested, | ||
| 46 | cause, | ||
| 47 | }), | ||
| 48 | }); | ||
| 49 | |||
| 50 | // Realpath the FILE itself (not just its directory): a symlink named | ||
| 51 | // like a script inside a contained directory must not escape. | ||
| 52 | const resolved = yield* Effect.tryPromise({ | ||
| 53 | try: () => NodeFSP.realpath(requested), | ||
| 54 | catch: (cause) => | ||
| 55 | new OrchestrationGetWorkflowScriptError({ | ||
| 56 | reason: "not-found", | ||
| 57 | scriptPath: requested, | ||
| 58 | cause, | ||
| 59 | }), | ||
| 60 | }); | ||
| 61 | |||
| 62 | if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) { | ||
| 63 | return yield* Effect.fail( | ||
| 64 | new OrchestrationGetWorkflowScriptError({ reason: "outside-root", scriptPath: resolved }), | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | if (NodePath.extname(resolved) !== ".js") { | ||
| 68 | return yield* Effect.fail( | ||
| 69 | new OrchestrationGetWorkflowScriptError({ reason: "not-js", scriptPath: resolved }), | ||
| 70 | ); | ||
| 71 | } | ||
| 72 | |||
| 73 | // TOCTOU-safe read (review finding): open FIRST, then verify what was | ||
| 74 | // actually opened via the file descriptor. Re-checking the path after | ||
| 75 | // open would race against a swap; fstat on the handle cannot. The two | ||
| 76 | // containment checks fail with their own tagged reasons (not manufactured | ||
| 77 | // Errors folded into read-failed); "read-failed" is reserved for genuine | ||
| 78 | // platform failures with the real cause attached. | ||
| 79 | const read = yield* Effect.tryPromise({ | ||
| 80 | try: async () => { | ||
| 81 | const handle = await NodeFSP.open(resolved, "r"); | ||
| 82 | try { | ||
| 83 | const stat = await handle.stat(); | ||
| 84 | if (!stat.isFile()) { | ||
| 85 | return { failure: "not-regular-file" as const }; | ||
| 86 | } | ||
| 87 | // The opened inode must be the same one realpath resolved to: a | ||
| 88 | // process swapping the path between realpath and open changes the | ||
| 89 | // inode, which this comparison catches. | ||
| 90 | const pathStat = await NodeFSP.lstat(resolved); | ||
| 91 | if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) { | ||
| 92 | return { failure: "changed-during-read" as const }; | ||
| 93 | } | ||
| 94 | const truncated = stat.size > SCRIPT_BYTE_CAP; | ||
| 95 | const buffer = Buffer.alloc(Math.min(stat.size, SCRIPT_BYTE_CAP)); | ||
| 96 | const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); | ||
| 97 | return { | ||
| 98 | contents: buffer.subarray(0, bytesRead).toString("utf8"), | ||
| 99 | truncated, | ||
| 100 | }; | ||
| 101 | } finally { | ||
| 102 | await handle.close(); | ||
| 103 | } | ||
| 104 | }, | ||
| 105 | catch: (cause) => | ||
| 106 | new OrchestrationGetWorkflowScriptError({ | ||
| 107 | reason: "read-failed", | ||
| 108 | scriptPath: resolved, | ||
| 109 | cause, | ||
| 110 | }), | ||
| 111 | }); | ||
| 112 | if ("failure" in read) { | ||
| 113 | return yield* new OrchestrationGetWorkflowScriptError({ | ||
| 114 | reason: read.failure, | ||
| 115 | scriptPath: resolved, | ||
| 116 | }); | ||
| 117 | } | ||
| 118 | |||
| 119 | return { | ||
| 120 | scriptPath: resolved, | ||
| 121 | contents: read.contents, | ||
| 122 | truncated: read.truncated, | ||
| 123 | }; | ||
| 124 | }); | ||
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+242| ... | @@ -55,6 +55,7 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> { | ... | @@ -55,6 +55,7 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> { |
| 55 | private failure: unknown | undefined; | 55 | private failure: unknown | undefined; |
| 56 | 56 | ||
| 57 | public readonly interruptCalls: Array<void> = []; | 57 | public readonly interruptCalls: Array<void> = []; |
| 58 | public readonly stopTaskCalls: Array<string> = []; | ||
| 58 | public readonly setModelCalls: Array<string | undefined> = []; | 59 | public readonly setModelCalls: Array<string | undefined> = []; |
| 59 | public readonly setPermissionModeCalls: Array<string> = []; | 60 | public readonly setPermissionModeCalls: Array<string> = []; |
| 60 | public readonly setMaxThinkingTokensCalls: Array<number | null> = []; | 61 | public readonly setMaxThinkingTokensCalls: Array<number | null> = []; |
| ... | @@ -98,6 +99,10 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> { | ... | @@ -98,6 +99,10 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> { |
| 98 | this.interruptCalls.push(undefined); | 99 | this.interruptCalls.push(undefined); |
| 99 | }; | 100 | }; |
| 100 | 101 | ||
| 102 | readonly stopTask = async (taskId: string): Promise<void> => { | ||
| 103 | this.stopTaskCalls.push(taskId); | ||
| 104 | }; | ||
| 105 | |||
| 101 | readonly setModel = async (model?: string): Promise<void> => { | 106 | readonly setModel = async (model?: string): Promise<void> => { |
| 102 | this.setModelCalls.push(model); | 107 | this.setModelCalls.push(model); |
| 103 | }; | 108 | }; |
| ... | @@ -1445,6 +1450,243 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1445,6 +1450,243 @@ describe("ClaudeAdapterLive", () => { |
| 1445 | ); | 1450 | ); |
| 1446 | }); | 1451 | }); |
| 1447 | 1452 | ||
| 1453 | it.effect("interruptTurn stops every live task before interrupting the turn", () => { | ||
| 1454 | const harness = makeHarness(); | ||
| 1455 | return Effect.gen(function* () { | ||
| 1456 | const adapter = yield* ClaudeAdapter; | ||
| 1457 | |||
| 1458 | // Wait for the three task.* runtime events to prove the lifecycle | ||
| 1459 | // handlers processed the emissions (no wall-clock sleeps under the | ||
| 1460 | // test clock). | ||
| 1461 | const taskEventsFiber = yield* adapter.streamEvents.pipe( | ||
| 1462 | Stream.filter((event) => event.type.startsWith("task.")), | ||
| 1463 | Stream.take(3), | ||
| 1464 | Stream.runCollect, | ||
| 1465 | Effect.forkChild, | ||
| 1466 | ); | ||
| 1467 | |||
| 1468 | const session = yield* adapter.startSession({ | ||
| 1469 | threadId: THREAD_ID, | ||
| 1470 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 1471 | runtimeMode: "full-access", | ||
| 1472 | }); | ||
| 1473 | yield* adapter.sendTurn({ | ||
| 1474 | threadId: session.threadId, | ||
| 1475 | input: "spawn agents", | ||
| 1476 | attachments: [], | ||
| 1477 | }); | ||
| 1478 | |||
| 1479 | harness.query.emit({ | ||
| 1480 | type: "system", | ||
| 1481 | subtype: "task_started", | ||
| 1482 | task_id: "task-live", | ||
| 1483 | description: "Agent A", | ||
| 1484 | task_type: "local_agent", | ||
| 1485 | uuid: "task-live-uuid", | ||
| 1486 | session_id: "sdk-session", | ||
| 1487 | } as unknown as SDKMessage); | ||
| 1488 | harness.query.emit({ | ||
| 1489 | type: "system", | ||
| 1490 | subtype: "task_started", | ||
| 1491 | task_id: "task-settled", | ||
| 1492 | description: "Agent B", | ||
| 1493 | task_type: "local_agent", | ||
| 1494 | uuid: "task-settled-uuid", | ||
| 1495 | session_id: "sdk-session", | ||
| 1496 | } as unknown as SDKMessage); | ||
| 1497 | harness.query.emit({ | ||
| 1498 | type: "system", | ||
| 1499 | subtype: "task_notification", | ||
| 1500 | task_id: "task-settled", | ||
| 1501 | status: "completed", | ||
| 1502 | output_file: "/tmp/task-settled.jsonl", | ||
| 1503 | summary: "done", | ||
| 1504 | uuid: "task-settled-done-uuid", | ||
| 1505 | session_id: "sdk-session", | ||
| 1506 | } as unknown as SDKMessage); | ||
| 1507 | |||
| 1508 | yield* Fiber.join(taskEventsFiber); | ||
| 1509 | |||
| 1510 | yield* adapter.interruptTurn(session.threadId); | ||
| 1511 | |||
| 1512 | // Only the still-live task is stopped; interrupt always fires after. | ||
| 1513 | assert.deepEqual(harness.query.stopTaskCalls, ["task-live"]); | ||
| 1514 | assert.equal(harness.query.interruptCalls.length, 1); | ||
| 1515 | }).pipe( | ||
| 1516 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | ||
| 1517 | Effect.provide(harness.layer), | ||
| 1518 | ); | ||
| 1519 | }); | ||
| 1520 | |||
| 1521 | it.effect("workflow member coalescing: identical snapshots suppress, changes emit", () => { | ||
| 1522 | const harness = makeHarness(); | ||
| 1523 | return Effect.gen(function* () { | ||
| 1524 | const adapter = yield* ClaudeAdapter; | ||
| 1525 | |||
| 1526 | // Collect task.progress until member-0's tick-3 emission lands, then | ||
| 1527 | // evaluate member emissions. | ||
| 1528 | const progressFiber = yield* adapter.streamEvents.pipe( | ||
| 1529 | Stream.filter((event) => event.type === "task.progress"), | ||
| 1530 | Stream.takeUntil( | ||
| 1531 | // Sentinel: member-0's tick-3 emission (tokens 20) — members are | ||
| 1532 | // emitted after the coordinator row within a tick. | ||
| 1533 | (event) => | ||
| 1534 | (event.payload as { taskId?: string }).taskId === "wf-coalesce:wf:0" && | ||
| 1535 | (event.payload as { typedUsage?: { totalTokens?: number } }).typedUsage?.totalTokens === | ||
| 1536 | 20, | ||
| 1537 | ), | ||
| 1538 | Stream.runCollect, | ||
| 1539 | Effect.forkChild, | ||
| 1540 | ); | ||
| 1541 | |||
| 1542 | const session = yield* adapter.startSession({ | ||
| 1543 | threadId: THREAD_ID, | ||
| 1544 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 1545 | runtimeMode: "full-access", | ||
| 1546 | }); | ||
| 1547 | yield* adapter.sendTurn({ | ||
| 1548 | threadId: session.threadId, | ||
| 1549 | input: "run workflow", | ||
| 1550 | attachments: [], | ||
| 1551 | }); | ||
| 1552 | |||
| 1553 | const memberSnapshot = (tokens: number) => [ | ||
| 1554 | { type: "workflow_phase", index: 0, title: "Work" }, | ||
| 1555 | { | ||
| 1556 | type: "workflow_agent", | ||
| 1557 | index: 0, | ||
| 1558 | state: "running", | ||
| 1559 | label: "member-0", | ||
| 1560 | phaseIndex: 0, | ||
| 1561 | tokens, | ||
| 1562 | }, | ||
| 1563 | { | ||
| 1564 | type: "workflow_agent", | ||
| 1565 | index: 1, | ||
| 1566 | state: "running", | ||
| 1567 | label: "member-1", | ||
| 1568 | phaseIndex: 0, | ||
| 1569 | tokens: 50, | ||
| 1570 | }, | ||
| 1571 | ]; | ||
| 1572 | const tick = (usageTotal: number, snapshot: ReturnType<typeof memberSnapshot>) => | ||
| 1573 | harness.query.emit({ | ||
| 1574 | type: "system", | ||
| 1575 | subtype: "task_progress", | ||
| 1576 | task_id: "wf-coalesce", | ||
| 1577 | description: "Coalescing workflow", | ||
| 1578 | usage: { total_tokens: usageTotal, tool_uses: 1, duration_ms: 10 }, | ||
| 1579 | workflow_progress: snapshot, | ||
| 1580 | uuid: `wf-tick-${usageTotal}`, | ||
| 1581 | session_id: "sdk-session", | ||
| 1582 | } as unknown as SDKMessage); | ||
| 1583 | |||
| 1584 | // Tick 1: both members are new -> 2 member events. | ||
| 1585 | tick(100, memberSnapshot(10)); | ||
| 1586 | // Tick 2: IDENTICAL member snapshot -> 0 member events (coordinator | ||
| 1587 | // usage changed, but members did not). | ||
| 1588 | tick(200, memberSnapshot(10)); | ||
| 1589 | // Tick 3: member-0's tokens advanced -> exactly 1 member event. | ||
| 1590 | tick(300, memberSnapshot(20)); | ||
| 1591 | |||
| 1592 | const progressEvents = Array.from(yield* Fiber.join(progressFiber)); | ||
| 1593 | const byMember = new Map<string, number>(); | ||
| 1594 | for (const event of progressEvents) { | ||
| 1595 | const taskId = (event.payload as { taskId: string }).taskId; | ||
| 1596 | if (!taskId.includes(":wf:")) continue; | ||
| 1597 | byMember.set(taskId, (byMember.get(taskId) ?? 0) + 1); | ||
| 1598 | } | ||
| 1599 | // member-0: tick 1 + tick 3. member-1: tick 1 only (tick 2 identical, | ||
| 1600 | // tick 3 unchanged). | ||
| 1601 | assert.equal(byMember.get("wf-coalesce:wf:0"), 2); | ||
| 1602 | assert.equal(byMember.get("wf-coalesce:wf:1"), 1); | ||
| 1603 | }).pipe( | ||
| 1604 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | ||
| 1605 | Effect.provide(harness.layer), | ||
| 1606 | ); | ||
| 1607 | }); | ||
| 1608 | |||
| 1609 | it.effect("task.started carries model/effort; subagent snapshots refine the model", () => { | ||
| 1610 | const harness = makeHarness(); | ||
| 1611 | return Effect.gen(function* () { | ||
| 1612 | const adapter = yield* ClaudeAdapter; | ||
| 1613 | |||
| 1614 | const taskEventsFiber = yield* adapter.streamEvents.pipe( | ||
| 1615 | Stream.filter((event) => event.type.startsWith("task.")), | ||
| 1616 | Stream.take(2), | ||
| 1617 | Stream.runCollect, | ||
| 1618 | Effect.forkChild, | ||
| 1619 | ); | ||
| 1620 | |||
| 1621 | const session = yield* adapter.startSession({ | ||
| 1622 | threadId: THREAD_ID, | ||
| 1623 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 1624 | modelSelection: createModelSelection( | ||
| 1625 | ProviderInstanceId.make("claudeAgent"), | ||
| 1626 | "claude-opus-4-6", | ||
| 1627 | [{ id: "effort", value: "max" }], | ||
| 1628 | ), | ||
| 1629 | runtimeMode: "full-access", | ||
| 1630 | }); | ||
| 1631 | yield* adapter.sendTurn({ | ||
| 1632 | threadId: session.threadId, | ||
| 1633 | input: "spawn an agent", | ||
| 1634 | attachments: [], | ||
| 1635 | }); | ||
| 1636 | |||
| 1637 | // No explicit model/effort on the launch input: the task inherits the | ||
| 1638 | // session's selection. | ||
| 1639 | harness.query.emit({ | ||
| 1640 | type: "system", | ||
| 1641 | subtype: "task_started", | ||
| 1642 | task_id: "task-model", | ||
| 1643 | description: "Agent M", | ||
| 1644 | task_type: "local_agent", | ||
| 1645 | tool_use_id: "toolu_agent_m", | ||
| 1646 | uuid: "task-model-uuid", | ||
| 1647 | session_id: "sdk-session", | ||
| 1648 | } as unknown as SDKMessage); | ||
| 1649 | // The subagent's assistant snapshot carries the authoritative API | ||
| 1650 | // model id, which refines the linkage on later rows. | ||
| 1651 | harness.query.emit({ | ||
| 1652 | type: "assistant", | ||
| 1653 | parent_tool_use_id: "toolu_agent_m", | ||
| 1654 | message: { | ||
| 1655 | model: "claude-sonnet-5[1m]", | ||
| 1656 | content: [], | ||
| 1657 | }, | ||
| 1658 | uuid: "subagent-snapshot-uuid", | ||
| 1659 | session_id: "sdk-session", | ||
| 1660 | } as unknown as SDKMessage); | ||
| 1661 | harness.query.emit({ | ||
| 1662 | type: "system", | ||
| 1663 | subtype: "task_progress", | ||
| 1664 | task_id: "task-model", | ||
| 1665 | description: "Agent M", | ||
| 1666 | usage: { total_tokens: 100, tool_uses: 1, duration_ms: 10 }, | ||
| 1667 | uuid: "task-model-progress-uuid", | ||
| 1668 | session_id: "sdk-session", | ||
| 1669 | } as unknown as SDKMessage); | ||
| 1670 | |||
| 1671 | const taskEvents = Array.from(yield* Fiber.join(taskEventsFiber)); | ||
| 1672 | const started = taskEvents[0]; | ||
| 1673 | assert.equal(started?.type, "task.started"); | ||
| 1674 | if (started?.type === "task.started") { | ||
| 1675 | assert.equal(started.payload.model, "claude-opus-4-6"); | ||
| 1676 | assert.equal(started.payload.effort, "max"); | ||
| 1677 | } | ||
| 1678 | const progress = taskEvents[1]; | ||
| 1679 | assert.equal(progress?.type, "task.progress"); | ||
| 1680 | if (progress?.type === "task.progress") { | ||
| 1681 | assert.equal(progress.payload.model, "claude-sonnet-5[1m]"); | ||
| 1682 | assert.equal(progress.payload.effort, "max"); | ||
| 1683 | } | ||
| 1684 | }).pipe( | ||
| 1685 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | ||
| 1686 | Effect.provide(harness.layer), | ||
| 1687 | ); | ||
| 1688 | }); | ||
| 1689 | |||
| 1448 | it.effect("closes the session when the Claude stream aborts after a turn starts", () => { | 1690 | it.effect("closes the session when the Claude stream aborts after a turn starts", () => { |
| 1449 | const harness = makeHarness(); | 1691 | const harness = makeHarness(); |
| 1450 | return Effect.gen(function* () { | 1692 | return Effect.gen(function* () { |
apps/server/src/provider/Layers/ClaudeAdapter.ts+586-12| ... | @@ -42,6 +42,10 @@ import { | ... | @@ -42,6 +42,10 @@ import { |
| 42 | RuntimeItemId, | 42 | RuntimeItemId, |
| 43 | RuntimeRequestId, | 43 | RuntimeRequestId, |
| 44 | RuntimeTaskId, | 44 | RuntimeTaskId, |
| 45 | type RuntimeTaskStatus, | ||
| 46 | type RuntimeTaskUsage, | ||
| 47 | type TaskAgentLinkage, | ||
| 48 | type TaskRunHandles, | ||
| 45 | ThreadId, | 49 | ThreadId, |
| 46 | TurnId, | 50 | TurnId, |
| 47 | type UserInputQuestion, | 51 | type UserInputQuestion, |
| ... | @@ -169,6 +173,9 @@ interface ToolInFlight { | ... | @@ -169,6 +173,9 @@ interface ToolInFlight { |
| 169 | readonly input: Record<string, unknown>; | 173 | readonly input: Record<string, unknown>; |
| 170 | readonly partialInputJson: string; | 174 | readonly partialInputJson: string; |
| 171 | readonly lastEmittedInputFingerprint?: string; | 175 | readonly lastEmittedInputFingerprint?: string; |
| 176 | /** Owning agent when this tool ran inside a subagent (see attribution note). */ | ||
| 177 | readonly agentId?: string; | ||
| 178 | readonly parentToolUseId?: string; | ||
| 172 | } | 179 | } |
| 173 | 180 | ||
| 174 | interface ClaudeTaskState { | 181 | interface ClaudeTaskState { |
| ... | @@ -178,6 +185,28 @@ interface ClaudeTaskState { | ... | @@ -178,6 +185,28 @@ interface ClaudeTaskState { |
| 178 | readonly blockedBy: Set<string>; | 185 | readonly blockedBy: Set<string>; |
| 179 | } | 186 | } |
| 180 | 187 | ||
| 188 | /** | ||
| 189 | * Agent identity captured from task_started and repeated on every subsequent | ||
| 190 | * task.* payload, so client folds can reconstruct an agent even when its | ||
| 191 | * start row aged out of activity retention. | ||
| 192 | */ | ||
| 193 | interface ClaudeTaskAgentState { | ||
| 194 | readonly taskId: string; | ||
| 195 | toolUseId: string | undefined; | ||
| 196 | description: string | undefined; | ||
| 197 | subagentType: string | undefined; | ||
| 198 | taskType: string | undefined; | ||
| 199 | workflowName: string | undefined; | ||
| 200 | skipTranscript: boolean; | ||
| 201 | runHandles: TaskRunHandles | undefined; | ||
| 202 | /** Set when this task was launched from inside a subagent. */ | ||
| 203 | owningAgentId: string | undefined; | ||
| 204 | /** Seeded from the launching tool's input; refined by the subagent's own | ||
| 205 | * assistant snapshots (authoritative API model). */ | ||
| 206 | model: string | undefined; | ||
| 207 | effort: string | undefined; | ||
| 208 | } | ||
| 209 | |||
| 181 | interface ClaudeSessionContext { | 210 | interface ClaudeSessionContext { |
| 182 | session: ProviderSession; | 211 | session: ProviderSession; |
| 183 | readonly promptQueue: Queue.Queue<PromptQueueItem>; | 212 | readonly promptQueue: Queue.Queue<PromptQueueItem>; |
| ... | @@ -186,6 +215,9 @@ interface ClaudeSessionContext { | ... | @@ -186,6 +215,9 @@ interface ClaudeSessionContext { |
| 186 | readonly startedAt: string; | 215 | readonly startedAt: string; |
| 187 | readonly basePermissionMode: PermissionMode | undefined; | 216 | readonly basePermissionMode: PermissionMode | undefined; |
| 188 | currentApiModelId: string | undefined; | 217 | currentApiModelId: string | undefined; |
| 218 | /** Effective effort for the session's turns; subagents without an explicit | ||
| 219 | * effort override inherit this. */ | ||
| 220 | currentEffort: string | undefined; | ||
| 189 | resumeSessionId: string | undefined; | 221 | resumeSessionId: string | undefined; |
| 190 | readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>; | 222 | readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>; |
| 191 | readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>; | 223 | readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>; |
| ... | @@ -195,6 +227,17 @@ interface ClaudeSessionContext { | ... | @@ -195,6 +227,17 @@ interface ClaudeSessionContext { |
| 195 | }>; | 227 | }>; |
| 196 | readonly inFlightTools: Map<number, ToolInFlight>; | 228 | readonly inFlightTools: Map<number, ToolInFlight>; |
| 197 | readonly claudeTasks: Map<string, ClaudeTaskState>; | 229 | readonly claudeTasks: Map<string, ClaudeTaskState>; |
| 230 | readonly taskAgents: Map<string, ClaudeTaskAgentState>; | ||
| 231 | /** | ||
| 232 | * Last emitted workflow-member fingerprint per member slot. A coordinator | ||
| 233 | * task_progress repeats the FULL member array every tick; without a | ||
| 234 | * material-transition filter one provider tick fans out into up to 100 | ||
| 235 | * runtime events (event-log writes, queue pressure, client reducer work) | ||
| 236 | * even when nothing changed for most members. | ||
| 237 | */ | ||
| 238 | readonly workflowMemberFingerprints: Map<string, string>; | ||
| 239 | /** Task ids that have started and not yet reached a terminal state. */ | ||
| 240 | readonly liveTaskIds: Set<string>; | ||
| 198 | turnState: ClaudeTurnState | undefined; | 241 | turnState: ClaudeTurnState | undefined; |
| 199 | lastKnownContextWindow: number | undefined; | 242 | lastKnownContextWindow: number | undefined; |
| 200 | lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; | 243 | lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; |
| ... | @@ -206,6 +249,8 @@ interface ClaudeSessionContext { | ... | @@ -206,6 +249,8 @@ interface ClaudeSessionContext { |
| 206 | 249 | ||
| 207 | interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> { | 250 | interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> { |
| 208 | readonly interrupt: () => Promise<void>; | 251 | readonly interrupt: () => Promise<void>; |
| 252 | /** SDK Query.stopTask — present on real queries; optional for test doubles. */ | ||
| 253 | readonly stopTask?: (taskId: string) => Promise<void>; | ||
| 209 | readonly setModel: (model?: string) => Promise<void>; | 254 | readonly setModel: (model?: string) => Promise<void>; |
| 210 | readonly setPermissionMode: (mode: PermissionMode) => Promise<void>; | 255 | readonly setPermissionMode: (mode: PermissionMode) => Promise<void>; |
| 211 | readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>; | 256 | readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>; |
| ... | @@ -826,6 +871,225 @@ function planStepsFromClaudeTasks(tasks: Map<string, ClaudeTaskState>): PlanStep | ... | @@ -826,6 +871,225 @@ function planStepsFromClaudeTasks(tasks: Map<string, ClaudeTaskState>): PlanStep |
| 826 | }); | 871 | }); |
| 827 | } | 872 | } |
| 828 | 873 | ||
| 874 | /** Only http/https survive; anything else (javascript:, file:, …) is dropped. */ | ||
| 875 | function sanitizeSessionUrl(value: unknown): string | undefined { | ||
| 876 | if (typeof value !== "string") { | ||
| 877 | return undefined; | ||
| 878 | } | ||
| 879 | const trimmed = value.trim(); | ||
| 880 | if (!/^https?:\/\//i.test(trimmed)) { | ||
| 881 | return undefined; | ||
| 882 | } | ||
| 883 | return trimmed; | ||
| 884 | } | ||
| 885 | |||
| 886 | function nonNegativeInt(value: unknown): number | undefined { | ||
| 887 | return typeof value === "number" && Number.isFinite(value) && value >= 0 | ||
| 888 | ? Math.floor(value) | ||
| 889 | : undefined; | ||
| 890 | } | ||
| 891 | |||
| 892 | function trimmedString(value: unknown): string | undefined { | ||
| 893 | if (typeof value !== "string") { | ||
| 894 | return undefined; | ||
| 895 | } | ||
| 896 | const trimmed = value.trim(); | ||
| 897 | return trimmed.length > 0 ? trimmed : undefined; | ||
| 898 | } | ||
| 899 | |||
| 900 | /** | ||
| 901 | * SDK task usage ({total_tokens, tool_uses, duration_ms}, sometimes with | ||
| 902 | * input/output/cache breakdowns) → the typed contract shape. Unknown or | ||
| 903 | * malformed input yields undefined rather than a partial guess. | ||
| 904 | */ | ||
| 905 | function normalizeTaskUsage(usage: unknown): RuntimeTaskUsage | undefined { | ||
| 906 | if (typeof usage !== "object" || usage === null) { | ||
| 907 | return undefined; | ||
| 908 | } | ||
| 909 | const record = usage as Record<string, unknown>; | ||
| 910 | const totalTokens = nonNegativeInt(record.total_tokens); | ||
| 911 | if (totalTokens === undefined) { | ||
| 912 | return undefined; | ||
| 913 | } | ||
| 914 | const inputTokens = nonNegativeInt(record.input_tokens); | ||
| 915 | const cachedInputTokens = nonNegativeInt(record.cache_read_input_tokens); | ||
| 916 | const outputTokens = nonNegativeInt(record.output_tokens); | ||
| 917 | const toolUses = nonNegativeInt(record.tool_uses); | ||
| 918 | const durationMs = nonNegativeInt(record.duration_ms); | ||
| 919 | return { | ||
| 920 | totalTokens, | ||
| 921 | ...(inputTokens !== undefined ? { inputTokens } : {}), | ||
| 922 | ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), | ||
| 923 | ...(outputTokens !== undefined ? { outputTokens } : {}), | ||
| 924 | ...(toolUses !== undefined ? { toolUses } : {}), | ||
| 925 | ...(durationMs !== undefined ? { durationMs } : {}), | ||
| 926 | }; | ||
| 927 | } | ||
| 928 | |||
| 929 | /** SDK task_updated patch status → the shared wire vocabulary. */ | ||
| 930 | const CLAUDE_TASK_PATCH_STATUS: Record<string, RuntimeTaskStatus> = { | ||
| 931 | pending: "pending", | ||
| 932 | running: "running", | ||
| 933 | completed: "completed", | ||
| 934 | failed: "failed", | ||
| 935 | killed: "cancelled", | ||
| 936 | paused: "idle", | ||
| 937 | }; | ||
| 938 | |||
| 939 | /** | ||
| 940 | * Resolves a stream message's parent_tool_use_id to the owning agent's | ||
| 941 | * taskId. The Task tool's tool_use_id is remembered on task_started; any | ||
| 942 | * subagent-forwarded block carries that id as its parent. Returns undefined | ||
| 943 | * for parent-conversation traffic. | ||
| 944 | */ | ||
| 945 | function agentIdForParentToolUse( | ||
| 946 | agents: Map<string, ClaudeTaskAgentState>, | ||
| 947 | parentToolUseId: string | null | undefined, | ||
| 948 | ): string | undefined { | ||
| 949 | if (parentToolUseId === null || parentToolUseId === undefined) { | ||
| 950 | return undefined; | ||
| 951 | } | ||
| 952 | for (const agent of agents.values()) { | ||
| 953 | if (agent.toolUseId === parentToolUseId) { | ||
| 954 | return agent.taskId; | ||
| 955 | } | ||
| 956 | } | ||
| 957 | return undefined; | ||
| 958 | } | ||
| 959 | |||
| 960 | /** | ||
| 961 | * Linkage bundle repeated on every task.* payload for `taskId`. Reads the | ||
| 962 | * remembered identity (from task_started) so progress/terminal rows are | ||
| 963 | * self-describing even when the start row ages out of activity retention. | ||
| 964 | */ | ||
| 965 | function taskLinkageFor( | ||
| 966 | agents: Map<string, ClaudeTaskAgentState>, | ||
| 967 | taskId: string, | ||
| 968 | ): TaskAgentLinkage { | ||
| 969 | const agent = agents.get(taskId); | ||
| 970 | if (!agent) { | ||
| 971 | return {}; | ||
| 972 | } | ||
| 973 | return { | ||
| 974 | ...(agent.taskType ? { taskType: agent.taskType } : {}), | ||
| 975 | ...(agent.owningAgentId ? { agentId: agent.owningAgentId } : {}), | ||
| 976 | ...(agent.description ? { title: agent.description } : {}), | ||
| 977 | ...(agent.subagentType ? { role: agent.subagentType } : {}), | ||
| 978 | ...(agent.model ? { model: agent.model } : {}), | ||
| 979 | ...(agent.effort ? { effort: agent.effort } : {}), | ||
| 980 | ...(agent.toolUseId ? { toolUseId: agent.toolUseId } : {}), | ||
| 981 | ...(agent.workflowName ? { workflowName: agent.workflowName } : {}), | ||
| 982 | ...(agent.runHandles ? { runHandles: agent.runHandles } : {}), | ||
| 983 | }; | ||
| 984 | } | ||
| 985 | |||
| 986 | const WORKFLOW_PHASE_CAP = 64; | ||
| 987 | const WORKFLOW_AGENT_CAP = 100; | ||
| 988 | |||
| 989 | interface ClaudeWorkflowAgentEntry { | ||
| 990 | readonly index: number; | ||
| 991 | readonly state: string; | ||
| 992 | readonly label: string | undefined; | ||
| 993 | readonly phaseIndex: number | undefined; | ||
| 994 | readonly phaseTitle: string | undefined; | ||
| 995 | readonly model: string | undefined; | ||
| 996 | readonly attempt: number | undefined; | ||
| 997 | readonly lastToolName: string | undefined; | ||
| 998 | readonly startedAt: string | undefined; | ||
| 999 | readonly error: string | undefined; | ||
| 1000 | readonly tokens: number | undefined; | ||
| 1001 | readonly toolCalls: number | undefined; | ||
| 1002 | } | ||
| 1003 | |||
| 1004 | interface ClaudeWorkflowProgress { | ||
| 1005 | readonly phases: ReadonlyArray<{ index: number; title: string }>; | ||
| 1006 | readonly agents: ReadonlyArray<ClaudeWorkflowAgentEntry>; | ||
| 1007 | } | ||
| 1008 | |||
| 1009 | /** | ||
| 1010 | * Defensive parse of the SDK's undeclared-but-real workflow_progress array on | ||
| 1011 | * task_progress messages (wire-confirmed; absent from sdk.d.ts). Unknown | ||
| 1012 | * shapes are skipped per-entry; phases and agents dedupe by index before | ||
| 1013 | * caps; a vanished field never throws. If the array disappears upstream the | ||
| 1014 | * caller keeps the coordinator row and plain task lifecycle. | ||
| 1015 | */ | ||
| 1016 | function parseWorkflowProgress(value: unknown): ClaudeWorkflowProgress | undefined { | ||
| 1017 | if (!Array.isArray(value) || value.length === 0) { | ||
| 1018 | return undefined; | ||
| 1019 | } | ||
| 1020 | const phasesByIndex = new Map<number, string>(); | ||
| 1021 | const agentsByIndex = new Map<number, ClaudeWorkflowAgentEntry>(); | ||
| 1022 | for (const entry of value) { | ||
| 1023 | if (typeof entry !== "object" || entry === null) { | ||
| 1024 | continue; | ||
| 1025 | } | ||
| 1026 | const record = entry as Record<string, unknown>; | ||
| 1027 | const entryType = trimmedString(record.type); | ||
| 1028 | if (entryType === "workflow_phase") { | ||
| 1029 | const index = nonNegativeInt(record.index); | ||
| 1030 | const title = trimmedString(record.title); | ||
| 1031 | if (index !== undefined && title && !phasesByIndex.has(index)) { | ||
| 1032 | phasesByIndex.set(index, title); | ||
| 1033 | } | ||
| 1034 | continue; | ||
| 1035 | } | ||
| 1036 | if (entryType !== "workflow_agent") { | ||
| 1037 | continue; | ||
| 1038 | } | ||
| 1039 | const index = nonNegativeInt(record.index); | ||
| 1040 | const state = trimmedString(record.state); | ||
| 1041 | if (index === undefined || !state || agentsByIndex.has(index)) { | ||
| 1042 | continue; | ||
| 1043 | } | ||
| 1044 | agentsByIndex.set(index, { | ||
| 1045 | index, | ||
| 1046 | state, | ||
| 1047 | label: trimmedString(record.label), | ||
| 1048 | phaseIndex: nonNegativeInt(record.phaseIndex), | ||
| 1049 | phaseTitle: trimmedString(record.phaseTitle), | ||
| 1050 | model: trimmedString(record.model), | ||
| 1051 | attempt: nonNegativeInt(record.attempt), | ||
| 1052 | lastToolName: trimmedString(record.lastToolName), | ||
| 1053 | startedAt: trimmedString(record.startedAt), | ||
| 1054 | error: trimmedString(record.error), | ||
| 1055 | tokens: nonNegativeInt(record.tokens), | ||
| 1056 | toolCalls: nonNegativeInt(record.toolCalls), | ||
| 1057 | }); | ||
| 1058 | } | ||
| 1059 | if (phasesByIndex.size === 0 && agentsByIndex.size === 0) { | ||
| 1060 | return undefined; | ||
| 1061 | } | ||
| 1062 | const phases = Array.from(phasesByIndex.entries()) | ||
| 1063 | .map(([index, title]) => ({ index, title })) | ||
| 1064 | .toSorted((a, b) => a.index - b.index) | ||
| 1065 | .slice(0, WORKFLOW_PHASE_CAP); | ||
| 1066 | const agents = Array.from(agentsByIndex.values()) | ||
| 1067 | .toSorted((a, b) => a.index - b.index) | ||
| 1068 | .slice(0, WORKFLOW_AGENT_CAP); | ||
| 1069 | return { phases, agents }; | ||
| 1070 | } | ||
| 1071 | |||
| 1072 | /** | ||
| 1073 | * Workflow member states from workflow_progress → shared task status. | ||
| 1074 | * Unknown states read running after startedAt, pending before it. | ||
| 1075 | */ | ||
| 1076 | function workflowAgentStatus(entry: ClaudeWorkflowAgentEntry): RuntimeTaskStatus { | ||
| 1077 | switch (entry.state) { | ||
| 1078 | case "queued": | ||
| 1079 | case "pending": | ||
| 1080 | return "pending"; | ||
| 1081 | case "start": | ||
| 1082 | case "running": | ||
| 1083 | return entry.startedAt === undefined ? "pending" : "running"; | ||
| 1084 | case "done": | ||
| 1085 | return "completed"; | ||
| 1086 | case "error": | ||
| 1087 | return "failed"; | ||
| 1088 | default: | ||
| 1089 | return entry.startedAt === undefined ? "pending" : "running"; | ||
| 1090 | } | ||
| 1091 | } | ||
| 1092 | |||
| 829 | function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string { | 1093 | function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string { |
| 830 | const commandValue = input.command ?? input.cmd; | 1094 | const commandValue = input.command ?? input.cmd; |
| 831 | const command = typeof commandValue === "string" ? commandValue : undefined; | 1095 | const command = typeof commandValue === "string" ? commandValue : undefined; |
| ... | @@ -833,17 +1097,17 @@ function summarizeToolRequest(toolName: string, input: Record<string, unknown>): | ... | @@ -833,17 +1097,17 @@ function summarizeToolRequest(toolName: string, input: Record<string, unknown>): |
| 833 | return `${toolName}: ${command.trim().slice(0, 400)}`; | 1097 | return `${toolName}: ${command.trim().slice(0, 400)}`; |
| 834 | } | 1098 | } |
| 835 | 1099 | ||
| 836 | // For agent/subagent tools, prefer human-readable description or prompt over raw JSON | 1100 | // For agent/subagent tools, prefer the human-readable description or prompt |
| 1101 | // over raw JSON. The structured subagent_type is carried separately on the | ||
| 1102 | // task.* payloads (role) — the label is display-only. | ||
| 837 | const itemType = classifyToolItemType(toolName); | 1103 | const itemType = classifyToolItemType(toolName); |
| 838 | if (itemType === "collab_agent_tool_call") { | 1104 | if (itemType === "collab_agent_tool_call") { |
| 839 | const description = | 1105 | const description = |
| 840 | typeof input.description === "string" ? input.description.trim() : undefined; | 1106 | typeof input.description === "string" ? input.description.trim() : undefined; |
| 841 | const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined; | 1107 | const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined; |
| 842 | const subagentType = | ||
| 843 | typeof input.subagent_type === "string" ? input.subagent_type.trim() : undefined; | ||
| 844 | const label = description || (prompt ? prompt.slice(0, 200) : undefined); | 1108 | const label = description || (prompt ? prompt.slice(0, 200) : undefined); |
| 845 | if (label) { | 1109 | if (label) { |
| 846 | return subagentType ? `${subagentType}: ${label}` : label; | 1110 | return label; |
| 847 | } | 1111 | } |
| 848 | } | 1112 | } |
| 849 | 1113 | ||
| ... | @@ -2078,6 +2342,32 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2078,6 +2342,32 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2078 | 2342 | ||
| 2079 | const { event } = message; | 2343 | const { event } = message; |
| 2080 | 2344 | ||
| 2345 | // Subagent-owned stream traffic (parent_tool_use_id set) must not write | ||
| 2346 | // into the parent transcript: with forwardSubagentText off the SDK still | ||
| 2347 | // forwards subagent tool_use/tool_result blocks and their wrapping | ||
| 2348 | // text/thinking deltas, and emitting them interleaved N subagents' | ||
| 2349 | // narration into the chat (live-test finding). Their results reach the | ||
| 2350 | // UI via the task.* lifecycle; their tool blocks are attributed and | ||
| 2351 | // re-homed by the quiet-timeline filter. | ||
| 2352 | const streamParentToolUseId = (message as { parent_tool_use_id?: string | null }) | ||
| 2353 | .parent_tool_use_id; | ||
| 2354 | if (streamParentToolUseId !== null && streamParentToolUseId !== undefined) { | ||
| 2355 | // Drop only the subagent's narration (text/thinking); tool_use blocks | ||
| 2356 | // and their input_json_delta frames must flow so attributed tool items | ||
| 2357 | // keep their inputs (review finding: dropping deltas emptied inputs). | ||
| 2358 | const dropStart = | ||
| 2359 | event.type === "content_block_start" && | ||
| 2360 | event.content_block.type !== "tool_use" && | ||
| 2361 | event.content_block.type !== "server_tool_use" && | ||
| 2362 | event.content_block.type !== "mcp_tool_use"; | ||
| 2363 | const dropDelta = | ||
| 2364 | event.type === "content_block_delta" && | ||
| 2365 | (event.delta.type === "text_delta" || event.delta.type === "thinking_delta"); | ||
| 2366 | if (dropStart || dropDelta) { | ||
| 2367 | return; | ||
| 2368 | } | ||
| 2369 | } | ||
| 2370 | |||
| 2081 | if (event.type === "message_delta") { | 2371 | if (event.type === "message_delta") { |
| 2082 | if (message.parent_tool_use_id !== null && message.parent_tool_use_id !== undefined) { | 2372 | if (message.parent_tool_use_id !== null && message.parent_tool_use_id !== undefined) { |
| 2083 | return; | 2373 | return; |
| ... | @@ -2205,6 +2495,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2205,6 +2495,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2205 | status: "inProgress", | 2495 | status: "inProgress", |
| 2206 | title: nextTool.title, | 2496 | title: nextTool.title, |
| 2207 | ...(nextTool.detail ? { detail: nextTool.detail } : {}), | 2497 | ...(nextTool.detail ? { detail: nextTool.detail } : {}), |
| 2498 | ...(nextTool.agentId ? { agentId: nextTool.agentId } : {}), | ||
| 2499 | ...(nextTool.parentToolUseId ? { parentToolUseId: nextTool.parentToolUseId } : {}), | ||
| 2208 | data: { | 2500 | data: { |
| 2209 | toolName: nextTool.toolName, | 2501 | toolName: nextTool.toolName, |
| 2210 | input: nextTool.input, | 2502 | input: nextTool.input, |
| ... | @@ -2274,6 +2566,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2274,6 +2566,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2274 | const inputFingerprint = | 2566 | const inputFingerprint = |
| 2275 | Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; | 2567 | Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; |
| 2276 | 2568 | ||
| 2569 | // Attribute tools that ran inside a subagent to their owning agent so | ||
| 2570 | // clients can re-home them out of the main timeline (quiet-timeline | ||
| 2571 | // guarantee): the SDK forwards subagent tool_use blocks tagged with the | ||
| 2572 | // spawning Task tool's id as parent_tool_use_id. | ||
| 2573 | const parentToolUseId = | ||
| 2574 | (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? undefined; | ||
| 2575 | const owningAgentId = agentIdForParentToolUse(context.taskAgents, parentToolUseId); | ||
| 2576 | |||
| 2277 | const tool: ToolInFlight = { | 2577 | const tool: ToolInFlight = { |
| 2278 | itemId, | 2578 | itemId, |
| 2279 | itemType, | 2579 | itemType, |
| ... | @@ -2283,6 +2583,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2283,6 +2583,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2283 | input: toolInput, | 2583 | input: toolInput, |
| 2284 | partialInputJson: "", | 2584 | partialInputJson: "", |
| 2285 | ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}), | 2585 | ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}), |
| 2586 | ...(owningAgentId ? { agentId: owningAgentId } : {}), | ||
| 2587 | ...(parentToolUseId ? { parentToolUseId } : {}), | ||
| 2286 | }; | 2588 | }; |
| 2287 | context.inFlightTools.set(index, tool); | 2589 | context.inFlightTools.set(index, tool); |
| 2288 | 2590 | ||
| ... | @@ -2300,6 +2602,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2300,6 +2602,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2300 | status: "inProgress", | 2602 | status: "inProgress", |
| 2301 | title: tool.title, | 2603 | title: tool.title, |
| 2302 | ...(tool.detail ? { detail: tool.detail } : {}), | 2604 | ...(tool.detail ? { detail: tool.detail } : {}), |
| 2605 | ...(tool.agentId ? { agentId: tool.agentId } : {}), | ||
| 2606 | ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), | ||
| 2303 | data: { | 2607 | data: { |
| 2304 | toolName: tool.toolName, | 2608 | toolName: tool.toolName, |
| 2305 | input: toolInput, | 2609 | input: toolInput, |
| ... | @@ -2378,6 +2682,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2378,6 +2682,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2378 | status: toolResult.isError ? "failed" : "inProgress", | 2682 | status: toolResult.isError ? "failed" : "inProgress", |
| 2379 | title: tool.title, | 2683 | title: tool.title, |
| 2380 | ...(tool.detail ? { detail: tool.detail } : {}), | 2684 | ...(tool.detail ? { detail: tool.detail } : {}), |
| 2685 | ...(tool.agentId ? { agentId: tool.agentId } : {}), | ||
| 2686 | ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), | ||
| 2381 | data: toolData, | 2687 | data: toolData, |
| 2382 | }, | 2688 | }, |
| 2383 | providerRefs: nativeProviderRefs(context, { | 2689 | providerRefs: nativeProviderRefs(context, { |
| ... | @@ -2430,6 +2736,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2430,6 +2736,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2430 | status: itemStatus, | 2736 | status: itemStatus, |
| 2431 | title: tool.title, | 2737 | title: tool.title, |
| 2432 | ...(tool.detail ? { detail: tool.detail } : {}), | 2738 | ...(tool.detail ? { detail: tool.detail } : {}), |
| 2739 | ...(tool.agentId ? { agentId: tool.agentId } : {}), | ||
| 2740 | ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), | ||
| 2433 | data: toolData, | 2741 | data: toolData, |
| 2434 | }, | 2742 | }, |
| 2435 | providerRefs: nativeProviderRefs(context, { | 2743 | providerRefs: nativeProviderRefs(context, { |
| ... | @@ -2442,6 +2750,43 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2442,6 +2750,43 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2442 | }, | 2750 | }, |
| 2443 | }); | 2751 | }); |
| 2444 | 2752 | ||
| 2753 | // The Workflow tool's result carries the run handles (runId, scriptPath, | ||
| 2754 | // transcriptDir, sessionUrl). Attach them to the workflow's task agent so | ||
| 2755 | // the next task.* payload advertises them to clients. | ||
| 2756 | if (!toolResult.isError && tool.toolName.toLowerCase() === "workflow" && toolUseResult) { | ||
| 2757 | const workflowTaskId = trimmedString(toolUseResult.taskId); | ||
| 2758 | if (workflowTaskId) { | ||
| 2759 | const runHandles: TaskRunHandles = { | ||
| 2760 | ...(trimmedString(toolUseResult.runId) | ||
| 2761 | ? { runId: trimmedString(toolUseResult.runId) } | ||
| 2762 | : {}), | ||
| 2763 | ...(trimmedString(toolUseResult.scriptPath) | ||
| 2764 | ? { scriptPath: trimmedString(toolUseResult.scriptPath) } | ||
| 2765 | : {}), | ||
| 2766 | ...(trimmedString(toolUseResult.transcriptDir) | ||
| 2767 | ? { transcriptDir: trimmedString(toolUseResult.transcriptDir) } | ||
| 2768 | : {}), | ||
| 2769 | ...(sanitizeSessionUrl(toolUseResult.sessionUrl) | ||
| 2770 | ? { sessionUrl: sanitizeSessionUrl(toolUseResult.sessionUrl) } | ||
| 2771 | : {}), | ||
| 2772 | }; | ||
| 2773 | const existing = context.taskAgents.get(workflowTaskId); | ||
| 2774 | context.taskAgents.set(workflowTaskId, { | ||
| 2775 | taskId: workflowTaskId, | ||
| 2776 | toolUseId: existing?.toolUseId ?? tool.itemId, | ||
| 2777 | description: existing?.description, | ||
| 2778 | subagentType: existing?.subagentType, | ||
| 2779 | taskType: existing?.taskType ?? "local_workflow", | ||
| 2780 | workflowName: existing?.workflowName, | ||
| 2781 | skipTranscript: existing?.skipTranscript ?? false, | ||
| 2782 | runHandles, | ||
| 2783 | owningAgentId: existing?.owningAgentId, | ||
| 2784 | model: existing?.model, | ||
| 2785 | effort: existing?.effort, | ||
| 2786 | }); | ||
| 2787 | } | ||
| 2788 | } | ||
| 2789 | |||
| 2445 | if ( | 2790 | if ( |
| 2446 | !toolResult.isError && | 2791 | !toolResult.isError && |
| 2447 | applyClaudeTaskToolResult(context.claudeTasks, tool, toolUseResult) | 2792 | applyClaudeTaskToolResult(context.claudeTasks, tool, toolUseResult) |
| ... | @@ -2465,6 +2810,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2465,6 +2810,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2465 | return; | 2810 | return; |
| 2466 | } | 2811 | } |
| 2467 | 2812 | ||
| 2813 | // Subagent-owned assistant snapshots (parent_tool_use_id set) are the | ||
| 2814 | // subagent's own conversation, not the parent's. Emitting them created | ||
| 2815 | // interleaved "Agent N done"-adjacent leak messages and spawned synthetic | ||
| 2816 | // turns per subagent completion (which also reset the Working timer). | ||
| 2817 | const assistantParentToolUseId = (message as { parent_tool_use_id?: string | null }) | ||
| 2818 | .parent_tool_use_id; | ||
| 2819 | if (assistantParentToolUseId !== null && assistantParentToolUseId !== undefined) { | ||
| 2820 | // The snapshot's message.model is the authoritative API model the | ||
| 2821 | // subagent actually ran on — refine the seeded launch-time value. | ||
| 2822 | const owningTaskId = agentIdForParentToolUse(context.taskAgents, assistantParentToolUseId); | ||
| 2823 | const snapshotModel = trimmedString(message.message.model); | ||
| 2824 | const owningAgent = owningTaskId ? context.taskAgents.get(owningTaskId) : undefined; | ||
| 2825 | if (owningAgent && snapshotModel) { | ||
| 2826 | owningAgent.model = snapshotModel; | ||
| 2827 | } | ||
| 2828 | context.lastAssistantUuid = message.uuid; | ||
| 2829 | yield* updateResumeCursor(context); | ||
| 2830 | return; | ||
| 2831 | } | ||
| 2832 | |||
| 2468 | // Auto-start a synthetic turn for assistant messages that arrive without | 2833 | // Auto-start a synthetic turn for assistant messages that arrive without |
| 2469 | // an active turn (e.g., background agent/subagent responses between user prompts). | 2834 | // an active turn (e.g., background agent/subagent responses between user prompts). |
| 2470 | if (!context.turnState) { | 2835 | if (!context.turnState) { |
| ... | @@ -2563,6 +2928,82 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2563,6 +2928,82 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2563 | yield* completeTurn(context, status, errorMessage, message); | 2928 | yield* completeTurn(context, status, errorMessage, message); |
| 2564 | }); | 2929 | }); |
| 2565 | 2930 | ||
| 2931 | /** | ||
| 2932 | * Synthesizes per-member task.progress rows from the coordinator's | ||
| 2933 | * workflow_progress array. Member identity is the stable slot | ||
| 2934 | * `<coordinatorTaskId>:wf:<index>` (never the per-attempt agent id, which | ||
| 2935 | * changes on retry and would split one member into duplicate rows). | ||
| 2936 | * timelineBypass keeps these out of the parent chat; the Agents surface and | ||
| 2937 | * workflow card consume them. | ||
| 2938 | */ | ||
| 2939 | const emitWorkflowMemberProgress = Effect.fn("emitWorkflowMemberProgress")(function* ( | ||
| 2940 | context: ClaudeSessionContext, | ||
| 2941 | base: Omit<ProviderRuntimeEvent, "type" | "payload">, | ||
| 2942 | message: Extract<SDKMessage, { type: "system"; subtype: "task_progress" }>, | ||
| 2943 | ) { | ||
| 2944 | const progress = parseWorkflowProgress( | ||
| 2945 | (message as unknown as Record<string, unknown>).workflow_progress, | ||
| 2946 | ); | ||
| 2947 | if (!progress) { | ||
| 2948 | return; | ||
| 2949 | } | ||
| 2950 | const coordinatorId = message.task_id; | ||
| 2951 | for (const entry of progress.agents) { | ||
| 2952 | const memberTaskId = `${coordinatorId}:wf:${entry.index}`; | ||
| 2953 | const status = workflowAgentStatus(entry); | ||
| 2954 | // Material-transition filter: the wire repeats every member each tick. | ||
| 2955 | // Emit only when something the client renders actually changed, so a | ||
| 2956 | // 100-agent fleet costs ~1 event per changed member instead of 100 | ||
| 2957 | // per tick (review finding: unbounded event amplification). | ||
| 2958 | const fingerprint = [ | ||
| 2959 | status, | ||
| 2960 | entry.label ?? "", | ||
| 2961 | entry.model ?? "", | ||
| 2962 | entry.lastToolName ?? "", | ||
| 2963 | entry.error ?? "", | ||
| 2964 | entry.tokens ?? "", | ||
| 2965 | entry.toolCalls ?? "", | ||
| 2966 | entry.phaseIndex ?? "", | ||
| 2967 | entry.phaseTitle ?? "", | ||
| 2968 | entry.attempt ?? "", | ||
| 2969 | ].join("\u001f"); | ||
| 2970 | if (context.workflowMemberFingerprints.get(memberTaskId) === fingerprint) { | ||
| 2971 | continue; | ||
| 2972 | } | ||
| 2973 | context.workflowMemberFingerprints.set(memberTaskId, fingerprint); | ||
| 2974 | const stamp = yield* makeEventStamp(); | ||
| 2975 | yield* offerRuntimeEvent({ | ||
| 2976 | ...base, | ||
| 2977 | eventId: stamp.eventId, | ||
| 2978 | createdAt: stamp.createdAt, | ||
| 2979 | type: "task.progress", | ||
| 2980 | payload: { | ||
| 2981 | taskId: RuntimeTaskId.make(memberTaskId), | ||
| 2982 | description: entry.label ?? `agent ${entry.index}`, | ||
| 2983 | status, | ||
| 2984 | ...(entry.error ? { error: entry.error } : {}), | ||
| 2985 | ...(entry.label ? { title: entry.label } : {}), | ||
| 2986 | ...(entry.model ? { model: entry.model } : {}), | ||
| 2987 | ...(entry.lastToolName ? { lastToolName: entry.lastToolName } : {}), | ||
| 2988 | ...(entry.tokens !== undefined | ||
| 2989 | ? { | ||
| 2990 | typedUsage: { | ||
| 2991 | totalTokens: entry.tokens, | ||
| 2992 | ...(entry.toolCalls !== undefined ? { toolUses: entry.toolCalls } : {}), | ||
| 2993 | }, | ||
| 2994 | } | ||
| 2995 | : {}), | ||
| 2996 | parentAgentId: coordinatorId, | ||
| 2997 | agentIndex: entry.index, | ||
| 2998 | ...(entry.phaseIndex !== undefined ? { phaseIndex: entry.phaseIndex } : {}), | ||
| 2999 | ...(entry.phaseTitle ? { phaseTitle: entry.phaseTitle } : {}), | ||
| 3000 | ...(entry.attempt !== undefined ? { attempt: entry.attempt } : {}), | ||
| 3001 | timelineBypass: true, | ||
| 3002 | }, | ||
| 3003 | }); | ||
| 3004 | } | ||
| 3005 | }); | ||
| 3006 | |||
| 2566 | const handleSystemMessage = Effect.fn("handleSystemMessage")(function* ( | 3007 | const handleSystemMessage = Effect.fn("handleSystemMessage")(function* ( |
| 2567 | context: ClaudeSessionContext, | 3008 | context: ClaudeSessionContext, |
| 2568 | message: SDKMessage, | 3009 | message: SDKMessage, |
| ... | @@ -2678,7 +3119,46 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2678,7 +3119,46 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2678 | }, | 3119 | }, |
| 2679 | }); | 3120 | }); |
| 2680 | return; | 3121 | return; |
| 2681 | case "task_started": | 3122 | case "task_started": { |
| 3123 | // A task launched by a tool that itself ran inside a subagent (the | ||
| 3124 | // in-flight tool carries agentId from parent_tool_use_id) is | ||
| 3125 | // agent-internal: a subagent's background shell, not parent work. | ||
| 3126 | const launchingTool = message.tool_use_id | ||
| 3127 | ? Array.from(context.inFlightTools.values()).find( | ||
| 3128 | (tool) => tool.itemId === message.tool_use_id, | ||
| 3129 | ) | ||
| 3130 | : undefined; | ||
| 3131 | const owningAgentId = launchingTool?.agentId; | ||
| 3132 | // Model/effort: the Agent tool's input carries explicit overrides; | ||
| 3133 | // absent ones inherit the session's selection (SDK behavior). | ||
| 3134 | // Subagent assistant snapshots later refine model with the | ||
| 3135 | // authoritative API id. AgentInput.effort may be a named level or an | ||
| 3136 | // integer. | ||
| 3137 | const launchInput = launchingTool?.input; | ||
| 3138 | const model = | ||
| 3139 | trimmedString(launchInput?.model) ?? trimmedString(context.session.model ?? undefined); | ||
| 3140 | const rawLaunchEffort = launchInput?.effort; | ||
| 3141 | const effort = | ||
| 3142 | trimmedString(rawLaunchEffort) ?? | ||
| 3143 | (typeof rawLaunchEffort === "number" && Number.isFinite(rawLaunchEffort) | ||
| 3144 | ? String(rawLaunchEffort) | ||
| 3145 | : context.currentEffort); | ||
| 3146 | // Remember the agent identity so every later task.* payload for this | ||
| 3147 | // taskId is self-describing (identity must survive activity retention). | ||
| 3148 | context.taskAgents.set(message.task_id, { | ||
| 3149 | taskId: message.task_id, | ||
| 3150 | toolUseId: message.tool_use_id, | ||
| 3151 | description: message.description, | ||
| 3152 | subagentType: message.subagent_type, | ||
| 3153 | taskType: message.task_type, | ||
| 3154 | workflowName: message.workflow_name, | ||
| 3155 | skipTranscript: message.skip_transcript === true, | ||
| 3156 | runHandles: context.taskAgents.get(message.task_id)?.runHandles, | ||
| 3157 | owningAgentId, | ||
| 3158 | model, | ||
| 3159 | effort, | ||
| 3160 | }); | ||
| 3161 | context.liveTaskIds.add(message.task_id); | ||
| 2682 | yield* offerRuntimeEvent({ | 3162 | yield* offerRuntimeEvent({ |
| 2683 | ...base, | 3163 | ...base, |
| 2684 | type: "task.started", | 3164 | type: "task.started", |
| ... | @@ -2686,10 +3166,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2686,10 +3166,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2686 | taskId: RuntimeTaskId.make(message.task_id), | 3166 | taskId: RuntimeTaskId.make(message.task_id), |
| 2687 | description: message.description, | 3167 | description: message.description, |
| 2688 | ...(message.task_type ? { taskType: message.task_type } : {}), | 3168 | ...(message.task_type ? { taskType: message.task_type } : {}), |
| 3169 | ...(owningAgentId ? { agentId: owningAgentId } : {}), | ||
| 3170 | ...(message.description ? { title: message.description } : {}), | ||
| 3171 | ...(message.subagent_type ? { role: message.subagent_type } : {}), | ||
| 3172 | ...(model ? { model } : {}), | ||
| 3173 | ...(effort ? { effort } : {}), | ||
| 3174 | ...(message.tool_use_id ? { toolUseId: message.tool_use_id } : {}), | ||
| 3175 | ...(message.workflow_name ? { workflowName: message.workflow_name } : {}), | ||
| 2689 | }, | 3176 | }, |
| 2690 | }); | 3177 | }); |
| 2691 | return; | 3178 | return; |
| 2692 | case "task_progress": | 3179 | } |
| 3180 | case "task_progress": { | ||
| 2693 | yield* emitThreadTokenUsage( | 3181 | yield* emitThreadTokenUsage( |
| 2694 | context, | 3182 | context, |
| 2695 | normalizeClaudeTaskProgressTokenUsage(message.usage, context), | 3183 | normalizeClaudeTaskProgressTokenUsage(message.usage, context), |
| ... | @@ -2698,6 +3186,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2698,6 +3186,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2698 | rawPayload: message, | 3186 | rawPayload: message, |
| 2699 | }, | 3187 | }, |
| 2700 | ); | 3188 | ); |
| 3189 | const linkage = taskLinkageFor(context.taskAgents, message.task_id); | ||
| 3190 | const typedUsage = normalizeTaskUsage(message.usage); | ||
| 3191 | // Phases ride on the coordinator's ONE progress row per tick. A | ||
| 3192 | // separate phases-only row shared the stable ingestion activity id | ||
| 3193 | // with this full row, and the thinner upsert overwrote usage and | ||
| 3194 | // progress text (review finding). | ||
| 3195 | const workflowPhases = parseWorkflowProgress( | ||
| 3196 | (message as unknown as Record<string, unknown>).workflow_progress, | ||
| 3197 | )?.phases; | ||
| 2701 | yield* offerRuntimeEvent({ | 3198 | yield* offerRuntimeEvent({ |
| 2702 | ...base, | 3199 | ...base, |
| 2703 | type: "task.progress", | 3200 | type: "task.progress", |
| ... | @@ -2706,16 +3203,48 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2706,16 +3203,48 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2706 | description: message.description, | 3203 | description: message.description, |
| 2707 | ...(message.summary ? { summary: message.summary } : {}), | 3204 | ...(message.summary ? { summary: message.summary } : {}), |
| 2708 | ...(message.usage ? { usage: message.usage } : {}), | 3205 | ...(message.usage ? { usage: message.usage } : {}), |
| 3206 | ...(typedUsage ? { typedUsage } : {}), | ||
| 2709 | ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), | 3207 | ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), |
| 3208 | ...(workflowPhases && workflowPhases.length > 0 ? { phases: workflowPhases } : {}), | ||
| 3209 | ...linkage, | ||
| 3210 | ...(message.subagent_type ? { role: message.subagent_type } : {}), | ||
| 2710 | }, | 3211 | }, |
| 2711 | }); | 3212 | }); |
| 3213 | yield* emitWorkflowMemberProgress(context, base, message); | ||
| 2712 | return; | 3214 | return; |
| 2713 | // Task state patch (status/backgrounded/end_time). No runtime mapping | 3215 | } |
| 2714 | // yet — the terminal task_notification reports the outcome — but it | 3216 | case "task_updated": { |
| 2715 | // must not surface as an unknown-subtype warning row. | 3217 | // Status patch (killed/paused/backgrounded/end_time/error) — main |
| 2716 | case "task_updated": | 3218 | // previously dropped this on the floor, losing all transitions. |
| 3219 | const patch = message.patch; | ||
| 3220 | const status = | ||
| 3221 | patch.status !== undefined ? CLAUDE_TASK_PATCH_STATUS[patch.status] : undefined; | ||
| 3222 | if (status === "completed" || status === "failed" || status === "cancelled") { | ||
| 3223 | context.liveTaskIds.delete(message.task_id); | ||
| 3224 | } | ||
| 3225 | const endedAt = | ||
| 3226 | typeof patch.end_time === "number" && Number.isFinite(patch.end_time) | ||
| 3227 | ? DateTime.formatIso(DateTime.makeUnsafe(patch.end_time)) | ||
| 3228 | : undefined; | ||
| 3229 | yield* offerRuntimeEvent({ | ||
| 3230 | ...base, | ||
| 3231 | type: "task.updated", | ||
| 3232 | payload: { | ||
| 3233 | taskId: RuntimeTaskId.make(message.task_id), | ||
| 3234 | ...(status ? { status } : {}), | ||
| 3235 | ...(patch.description ? { description: patch.description } : {}), | ||
| 3236 | ...(patch.error ? { error: patch.error } : {}), | ||
| 3237 | ...(endedAt ? { endedAt } : {}), | ||
| 3238 | ...(patch.is_backgrounded !== undefined | ||
| 3239 | ? { isBackgrounded: patch.is_backgrounded } | ||
| 3240 | : {}), | ||
| 3241 | ...taskLinkageFor(context.taskAgents, message.task_id), | ||
| 3242 | }, | ||
| 3243 | }); | ||
| 2717 | return; | 3244 | return; |
| 2718 | case "task_notification": | 3245 | } |
| 3246 | case "task_notification": { | ||
| 3247 | context.liveTaskIds.delete(message.task_id); | ||
| 2719 | yield* emitThreadTokenUsage( | 3248 | yield* emitThreadTokenUsage( |
| 2720 | context, | 3249 | context, |
| 2721 | normalizeClaudeTaskProgressTokenUsage(message.usage, context), | 3250 | normalizeClaudeTaskProgressTokenUsage(message.usage, context), |
| ... | @@ -2724,6 +3253,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2724,6 +3253,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2724 | rawPayload: message, | 3253 | rawPayload: message, |
| 2725 | }, | 3254 | }, |
| 2726 | ); | 3255 | ); |
| 3256 | const typedUsage = normalizeTaskUsage(message.usage); | ||
| 2727 | yield* offerRuntimeEvent({ | 3257 | yield* offerRuntimeEvent({ |
| 2728 | ...base, | 3258 | ...base, |
| 2729 | type: "task.completed", | 3259 | type: "task.completed", |
| ... | @@ -2732,9 +3262,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2732,9 +3262,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2732 | status: message.status, | 3262 | status: message.status, |
| 2733 | ...(message.summary ? { summary: message.summary } : {}), | 3263 | ...(message.summary ? { summary: message.summary } : {}), |
| 2734 | ...(message.usage ? { usage: message.usage } : {}), | 3264 | ...(message.usage ? { usage: message.usage } : {}), |
| 3265 | ...(typedUsage ? { typedUsage } : {}), | ||
| 3266 | ...(message.output_file ? { outputFile: message.output_file } : {}), | ||
| 3267 | ...taskLinkageFor(context.taskAgents, message.task_id), | ||
| 2735 | }, | 3268 | }, |
| 2736 | }); | 3269 | }); |
| 2737 | return; | 3270 | return; |
| 3271 | } | ||
| 2738 | case "files_persisted": | 3272 | case "files_persisted": |
| 2739 | yield* offerRuntimeEvent({ | 3273 | yield* offerRuntimeEvent({ |
| 2740 | ...base, | 3274 | ...base, |
| ... | @@ -2870,7 +3404,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2870,7 +3404,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2870 | toolUseId: message.tool_use_id, | 3404 | toolUseId: message.tool_use_id, |
| 2871 | toolName: message.tool_name, | 3405 | toolName: message.tool_name, |
| 2872 | elapsedSeconds: message.elapsed_time_seconds, | 3406 | elapsedSeconds: message.elapsed_time_seconds, |
| 2873 | ...(message.task_id ? { summary: `task:${message.task_id}` } : {}), | 3407 | ...(message.task_id ? { taskId: RuntimeTaskId.make(message.task_id) } : {}), |
| 3408 | ...(message.parent_tool_use_id !== null | ||
| 3409 | ? { parentToolUseId: message.parent_tool_use_id } | ||
| 3410 | : {}), | ||
| 2874 | }, | 3411 | }, |
| 2875 | }); | 3412 | }); |
| 2876 | return; | 3413 | return; |
| ... | @@ -3192,6 +3729,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3192,6 +3729,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3192 | const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>(); | 3729 | const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>(); |
| 3193 | const inFlightTools = new Map<number, ToolInFlight>(); | 3730 | const inFlightTools = new Map<number, ToolInFlight>(); |
| 3194 | const claudeTasks = new Map<string, ClaudeTaskState>(); | 3731 | const claudeTasks = new Map<string, ClaudeTaskState>(); |
| 3732 | const taskAgents = new Map<string, ClaudeTaskAgentState>(); | ||
| 3733 | const workflowMemberFingerprints = new Map<string, string>(); | ||
| 3734 | const liveTaskIds = new Set<string>(); | ||
| 3195 | 3735 | ||
| 3196 | const contextRef = yield* Ref.make<ClaudeSessionContext | undefined>(undefined); | 3736 | const contextRef = yield* Ref.make<ClaudeSessionContext | undefined>(undefined); |
| 3197 | 3737 | ||
| ... | @@ -3628,12 +4168,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3628,12 +4168,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3628 | startedAt, | 4168 | startedAt, |
| 3629 | basePermissionMode: permissionMode, | 4169 | basePermissionMode: permissionMode, |
| 3630 | currentApiModelId: apiModelId, | 4170 | currentApiModelId: apiModelId, |
| 4171 | currentEffort: effectiveEffort ?? undefined, | ||
| 3631 | resumeSessionId: sessionId, | 4172 | resumeSessionId: sessionId, |
| 3632 | pendingApprovals, | 4173 | pendingApprovals, |
| 3633 | pendingUserInputs, | 4174 | pendingUserInputs, |
| 3634 | turns: [], | 4175 | turns: [], |
| 3635 | inFlightTools, | 4176 | inFlightTools, |
| 3636 | claudeTasks, | 4177 | claudeTasks, |
| 4178 | taskAgents, | ||
| 4179 | workflowMemberFingerprints, | ||
| 4180 | liveTaskIds, | ||
| 3637 | turnState: undefined, | 4181 | turnState: undefined, |
| 3638 | lastKnownContextWindow: initialContextWindow, | 4182 | lastKnownContextWindow: initialContextWindow, |
| 3639 | lastKnownTokenUsage: undefined, | 4183 | lastKnownTokenUsage: undefined, |
| ... | @@ -3750,6 +4294,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3750,6 +4294,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3750 | ...context.session, | 4294 | ...context.session, |
| 3751 | model: modelSelection.model, | 4295 | model: modelSelection.model, |
| 3752 | }; | 4296 | }; |
| 4297 | const turnCaps = getClaudeModelCapabilities(modelSelection.model); | ||
| 4298 | const turnEffort = resolveClaudeEffort( | ||
| 4299 | turnCaps, | ||
| 4300 | getModelSelectionStringOptionValue(modelSelection, "effort"), | ||
| 4301 | ); | ||
| 4302 | context.currentEffort = | ||
| 4303 | getEffectiveClaudeAgentEffort(turnEffort ?? null, modelSelection.model) ?? undefined; | ||
| 3753 | } | 4304 | } |
| 3754 | 4305 | ||
| 3755 | // Apply interaction mode by switching the SDK's permission mode. | 4306 | // Apply interaction mode by switching the SDK's permission mode. |
| ... | @@ -3825,6 +4376,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3825,6 +4376,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3825 | const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( | 4376 | const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( |
| 3826 | function* (threadId, _turnId) { | 4377 | function* (threadId, _turnId) { |
| 3827 | const context = yield* requireSession(threadId); | 4378 | const context = yield* requireSession(threadId); |
| 4379 | // Stop-everything semantics: users reach for Stop precisely when a | ||
| 4380 | // fleet ran away. interrupt() alone only ends the parent turn — | ||
| 4381 | // background subagents/shells keep running and keep burning tokens. | ||
| 4382 | // Stop every live task first (best-effort per task: one refusal must | ||
| 4383 | // not strand the rest or block the turn interrupt), then interrupt. | ||
| 4384 | if (context.query.stopTask && context.liveTaskIds.size > 0) { | ||
| 4385 | const liveIds = Array.from(context.liveTaskIds); | ||
| 4386 | // Bounded: a wedged child's stopTask promise may never settle | ||
| 4387 | // (Effect.ignore handles rejection, not non-resolution), and the | ||
| 4388 | // parent interrupt below MUST still run — Stop matters most during | ||
| 4389 | // runaway fleets (review finding). Per-task timeout keeps one hung | ||
| 4390 | // child from consuming the whole budget. | ||
| 4391 | yield* Effect.forEach( | ||
| 4392 | liveIds, | ||
| 4393 | (taskId) => | ||
| 4394 | Effect.tryPromise({ | ||
| 4395 | // Invoke through the query object: SDK methods rely on `this`. | ||
| 4396 | try: () => context.query.stopTask!(taskId), | ||
| 4397 | catch: () => undefined, | ||
| 4398 | }).pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), | ||
| 4399 | { concurrency: 8, discard: true }, | ||
| 4400 | ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); | ||
| 4401 | } | ||
| 3828 | yield* Effect.tryPromise({ | 4402 | yield* Effect.tryPromise({ |
| 3829 | try: () => context.query.interrupt(), | 4403 | try: () => context.query.interrupt(), |
| 3830 | catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), | 4404 | catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), |
apps/server/src/provider/Layers/CodexAdapter.ts+268| ... | @@ -20,6 +20,8 @@ import { | ... | @@ -20,6 +20,8 @@ import { |
| 20 | type ProviderUserInputAnswers, | 20 | type ProviderUserInputAnswers, |
| 21 | RuntimeItemId, | 21 | RuntimeItemId, |
| 22 | RuntimeRequestId, | 22 | RuntimeRequestId, |
| 23 | RuntimeTaskId, | ||
| 24 | type RuntimeTaskUsage, | ||
| 23 | ProviderApprovalDecision, | 25 | ProviderApprovalDecision, |
| 24 | ThreadId, | 26 | ThreadId, |
| 25 | ProviderSendTurnInput, | 27 | ProviderSendTurnInput, |
| ... | @@ -494,10 +496,276 @@ function mapItemLifecycle( | ... | @@ -494,10 +496,276 @@ function mapItemLifecycle( |
| 494 | }; | 496 | }; |
| 495 | } | 497 | } |
| 496 | 498 | ||
| 499 | /** | ||
| 500 | * Maps the session runtime's synthetic `collabAgent/*` events (native | ||
| 501 | * multi-agent v2 child-thread signals) into the shared task.* lifecycle. | ||
| 502 | * Agent identity = child thread id; nickname is the display title, role is | ||
| 503 | * agentRole (fallback: last agentPath segment, then "general-purpose"). | ||
| 504 | * A completed child turn is idle (resumable), not terminal. timelineBypass | ||
| 505 | * keeps these rows out of the parent chat. | ||
| 506 | */ | ||
| 507 | function mapCollabAgentEvent( | ||
| 508 | event: ProviderEvent, | ||
| 509 | canonicalThreadId: ThreadId, | ||
| 510 | ): ReadonlyArray<ProviderRuntimeEvent> { | ||
| 511 | const payload = | ||
| 512 | typeof event.payload === "object" && event.payload !== null | ||
| 513 | ? (event.payload as Record<string, unknown>) | ||
| 514 | : undefined; | ||
| 515 | const agentThreadId = typeof payload?.agentThreadId === "string" ? payload.agentThreadId : ""; | ||
| 516 | if (!payload || agentThreadId.length === 0) { | ||
| 517 | return []; | ||
| 518 | } | ||
| 519 | const base = runtimeEventBase(event, canonicalThreadId); | ||
| 520 | const taskId = RuntimeTaskId.make(agentThreadId); | ||
| 521 | const agentPath = typeof payload.agentPath === "string" ? payload.agentPath : undefined; | ||
| 522 | const pathLeaf = agentPath?.split("/").findLast((segment) => segment.length > 0); | ||
| 523 | const nickname = typeof payload.nickname === "string" ? payload.nickname : undefined; | ||
| 524 | const role = | ||
| 525 | (typeof payload.role === "string" ? payload.role : undefined) ?? pathLeaf ?? "general-purpose"; | ||
| 526 | // A bare thread id is not a name. Omitting the title lets the client fold | ||
| 527 | // keep the real one from task.started instead of clobbering it (probe | ||
| 528 | // finding: progress rows renamed math_one to its UUID). | ||
| 529 | const knownName = nickname ?? pathLeaf; | ||
| 530 | const title = knownName ?? agentThreadId; | ||
| 531 | // Identity repeated on every status patch so rows are self-describing when | ||
| 532 | // the start row ages out of activity retention (review finding: a | ||
| 533 | // reconstructed agent had a UUID name and no role/path). | ||
| 534 | const statusLinkage = { | ||
| 535 | role, | ||
| 536 | ...(knownName ? { title: knownName } : {}), | ||
| 537 | ...(agentPath ? { agentPath } : {}), | ||
| 538 | timelineBypass: true, | ||
| 539 | } as const; | ||
| 540 | |||
| 541 | switch (event.method) { | ||
| 542 | case "collabAgent/started": | ||
| 543 | return [ | ||
| 544 | { | ||
| 545 | ...base, | ||
| 546 | type: "task.started", | ||
| 547 | payload: { | ||
| 548 | taskId, | ||
| 549 | description: title, | ||
| 550 | title, | ||
| 551 | role, | ||
| 552 | ...(agentPath ? { agentPath } : {}), | ||
| 553 | ...(typeof payload.parentThreadId === "string" | ||
| 554 | ? { parentAgentId: payload.parentThreadId } | ||
| 555 | : {}), | ||
| 556 | timelineBypass: true, | ||
| 557 | }, | ||
| 558 | }, | ||
| 559 | ]; | ||
| 560 | case "collabAgent/activity": { | ||
| 561 | const activityKind = typeof payload.activityKind === "string" ? payload.activityKind : ""; | ||
| 562 | if (activityKind === "interrupted") { | ||
| 563 | return [ | ||
| 564 | { | ||
| 565 | ...base, | ||
| 566 | type: "task.updated", | ||
| 567 | payload: { taskId, status: "interrupted", ...statusLinkage }, | ||
| 568 | }, | ||
| 569 | ]; | ||
| 570 | } | ||
| 571 | if (activityKind === "started") { | ||
| 572 | // Wire-probe finding: children often register via subAgentActivity | ||
| 573 | // alone (no thread/started with a spawn source), so this is the one | ||
| 574 | // shot at a task.started with a real name — agentPath leaf beats a | ||
| 575 | // bare thread-id title. | ||
| 576 | return [ | ||
| 577 | { | ||
| 578 | ...base, | ||
| 579 | type: "task.started", | ||
| 580 | payload: { | ||
| 581 | taskId, | ||
| 582 | description: title, | ||
| 583 | title, | ||
| 584 | role, | ||
| 585 | ...(agentPath ? { agentPath } : {}), | ||
| 586 | timelineBypass: true, | ||
| 587 | }, | ||
| 588 | }, | ||
| 589 | ]; | ||
| 590 | } | ||
| 591 | // interacted → the child is (again) actively driven. | ||
| 592 | return [ | ||
| 593 | { | ||
| 594 | ...base, | ||
| 595 | type: "task.updated", | ||
| 596 | payload: { taskId, status: "running", ...statusLinkage }, | ||
| 597 | }, | ||
| 598 | ]; | ||
| 599 | } | ||
| 600 | case "collabAgent/turnStarted": | ||
| 601 | return [ | ||
| 602 | { | ||
| 603 | ...base, | ||
| 604 | type: "task.updated", | ||
| 605 | payload: { taskId, status: "running", ...statusLinkage }, | ||
| 606 | }, | ||
| 607 | ]; | ||
| 608 | case "collabAgent/turnCompleted": { | ||
| 609 | // Idle, not terminal: the identity is resumable via sendInput/resume. | ||
| 610 | const turn = | ||
| 611 | typeof payload.turn === "object" && payload.turn !== null | ||
| 612 | ? (payload.turn as Record<string, unknown>) | ||
| 613 | : undefined; | ||
| 614 | const turnStatus = typeof turn?.status === "string" ? turn.status : undefined; | ||
| 615 | const status = | ||
| 616 | turnStatus === "failed" | ||
| 617 | ? ("failed" as const) | ||
| 618 | : turnStatus === "interrupted" | ||
| 619 | ? ("interrupted" as const) | ||
| 620 | : ("idle" as const); | ||
| 621 | return [ | ||
| 622 | { | ||
| 623 | ...base, | ||
| 624 | type: "task.updated", | ||
| 625 | payload: { taskId, status, ...statusLinkage }, | ||
| 626 | }, | ||
| 627 | ]; | ||
| 628 | } | ||
| 629 | case "collabAgent/statusChanged": { | ||
| 630 | const status = | ||
| 631 | typeof payload.status === "object" && payload.status !== null | ||
| 632 | ? (payload.status as Record<string, unknown>) | ||
| 633 | : undefined; | ||
| 634 | const statusType = typeof status?.type === "string" ? status.type : undefined; | ||
| 635 | if (statusType === "systemError") { | ||
| 636 | // Silently dropping this once left children stuck running forever. | ||
| 637 | return [ | ||
| 638 | { | ||
| 639 | ...base, | ||
| 640 | type: "task.updated", | ||
| 641 | payload: { taskId, status: "failed", ...statusLinkage }, | ||
| 642 | }, | ||
| 643 | ]; | ||
| 644 | } | ||
| 645 | if (statusType === "active") { | ||
| 646 | const flags = Array.isArray(status?.activeFlags) ? status.activeFlags : []; | ||
| 647 | const waiting = flags.some( | ||
| 648 | (flag) => flag === "waitingOnApproval" || flag === "waitingOnUserInput", | ||
| 649 | ); | ||
| 650 | return [ | ||
| 651 | { | ||
| 652 | ...base, | ||
| 653 | type: "task.updated", | ||
| 654 | payload: { taskId, status: waiting ? "waiting" : "running", ...statusLinkage }, | ||
| 655 | }, | ||
| 656 | ]; | ||
| 657 | } | ||
| 658 | if (statusType === "idle") { | ||
| 659 | return [ | ||
| 660 | { | ||
| 661 | ...base, | ||
| 662 | type: "task.updated", | ||
| 663 | payload: { taskId, status: "idle", ...statusLinkage }, | ||
| 664 | }, | ||
| 665 | ]; | ||
| 666 | } | ||
| 667 | return []; | ||
| 668 | } | ||
| 669 | case "collabAgent/tokenUsage": { | ||
| 670 | // Cumulative per child thread: always the `total` breakdown, never | ||
| 671 | // `last` (which shrinks on follow-ups). Client folds max-merge. | ||
| 672 | const tokenUsage = | ||
| 673 | typeof payload.tokenUsage === "object" && payload.tokenUsage !== null | ||
| 674 | ? (payload.tokenUsage as Record<string, unknown>) | ||
| 675 | : undefined; | ||
| 676 | const total = | ||
| 677 | typeof tokenUsage?.total === "object" && tokenUsage.total !== null | ||
| 678 | ? (tokenUsage.total as Record<string, unknown>) | ||
| 679 | : undefined; | ||
| 680 | const count = (value: unknown): number | undefined => | ||
| 681 | typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; | ||
| 682 | // Same validation as every other field: RuntimeTaskUsage.totalTokens | ||
| 683 | // is NonNegativeInt, so NaN/Infinity/negative wire values must miss. | ||
| 684 | const totalTokens = count(total?.totalTokens); | ||
| 685 | if (totalTokens === undefined) { | ||
| 686 | return []; | ||
| 687 | } | ||
| 688 | const typedUsage: RuntimeTaskUsage = { | ||
| 689 | totalTokens, | ||
| 690 | ...(count(total?.inputTokens) !== undefined | ||
| 691 | ? { inputTokens: count(total?.inputTokens) } | ||
| 692 | : {}), | ||
| 693 | ...(count(total?.cachedInputTokens) !== undefined | ||
| 694 | ? { cachedInputTokens: count(total?.cachedInputTokens) } | ||
| 695 | : {}), | ||
| 696 | ...(count(total?.outputTokens) !== undefined | ||
| 697 | ? { outputTokens: count(total?.outputTokens) } | ||
| 698 | : {}), | ||
| 699 | ...(count(total?.reasoningOutputTokens) !== undefined | ||
| 700 | ? { reasoningOutputTokens: count(total?.reasoningOutputTokens) } | ||
| 701 | : {}), | ||
| 702 | }; | ||
| 703 | return [ | ||
| 704 | { | ||
| 705 | ...base, | ||
| 706 | type: "task.progress", | ||
| 707 | payload: { | ||
| 708 | taskId, | ||
| 709 | description: title, | ||
| 710 | ...(knownName ? { title: knownName } : {}), | ||
| 711 | typedUsage, | ||
| 712 | timelineBypass: true, | ||
| 713 | }, | ||
| 714 | }, | ||
| 715 | ]; | ||
| 716 | } | ||
| 717 | case "collabAgent/item": { | ||
| 718 | const item = | ||
| 719 | typeof payload.item === "object" && payload.item !== null | ||
| 720 | ? (payload.item as Record<string, unknown>) | ||
| 721 | : undefined; | ||
| 722 | const itemTypeRaw = typeof item?.type === "string" ? item.type : undefined; | ||
| 723 | if (!itemTypeRaw) { | ||
| 724 | return []; | ||
| 725 | } | ||
| 726 | // A loose summary from the raw item: the child stream is untyped at | ||
| 727 | // this boundary (synthetic event payload), so read best-effort fields | ||
| 728 | // rather than force a schema decode. | ||
| 729 | const looseSummary = | ||
| 730 | (typeof item?.command === "string" ? item.command : undefined) ?? | ||
| 731 | (typeof item?.title === "string" ? item.title : undefined) ?? | ||
| 732 | (typeof item?.query === "string" ? item.query : undefined); | ||
| 733 | const canonical = toCanonicalItemType(itemTypeRaw); | ||
| 734 | const summary = looseSummary ?? canonical.replaceAll("_", " "); | ||
| 735 | return [ | ||
| 736 | { | ||
| 737 | ...base, | ||
| 738 | type: "task.progress", | ||
| 739 | payload: { | ||
| 740 | taskId, | ||
| 741 | description: title, | ||
| 742 | ...(knownName ? { title: knownName } : {}), | ||
| 743 | summary, | ||
| 744 | timelineBypass: true, | ||
| 745 | }, | ||
| 746 | }, | ||
| 747 | ]; | ||
| 748 | } | ||
| 749 | case "collabAgent/closed": | ||
| 750 | return [ | ||
| 751 | { | ||
| 752 | ...base, | ||
| 753 | type: "task.updated", | ||
| 754 | payload: { taskId, status: "interrupted", ...statusLinkage }, | ||
| 755 | }, | ||
| 756 | ]; | ||
| 757 | default: | ||
| 758 | return []; | ||
| 759 | } | ||
| 760 | } | ||
| 761 | |||
| 497 | function mapToRuntimeEvents( | 762 | function mapToRuntimeEvents( |
| 498 | event: ProviderEvent, | 763 | event: ProviderEvent, |
| 499 | canonicalThreadId: ThreadId, | 764 | canonicalThreadId: ThreadId, |
| 500 | ): ReadonlyArray<ProviderRuntimeEvent> { | 765 | ): ReadonlyArray<ProviderRuntimeEvent> { |
| 766 | if (event.kind === "notification" && event.method.startsWith("collabAgent/")) { | ||
| 767 | return mapCollabAgentEvent(event, canonicalThreadId); | ||
| 768 | } | ||
| 501 | if (event.kind === "error") { | 769 | if (event.kind === "error") { |
| 502 | if (!event.message) { | 770 | if (!event.message) { |
| 503 | return []; | 771 | return []; |
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts created+248| ... | @@ -0,0 +1,248 @@ | ||
| 1 | /** | ||
| 2 | * Runtime-level collab regression: boots the REAL CodexSessionRuntime against | ||
| 3 | * a scripted mock app-server peer that replays the captured multi-agent wire | ||
| 4 | * sequence (codexMultiAgentWire.json) plus the shapes the capture alone can't | ||
| 5 | * script (receiver-turn bookkeeping via collabAgentToolCall, child terminal | ||
| 6 | * lifecycle, approval pass-through). This is the layer the pure routing-table | ||
| 7 | * test can't reach: ordering between the legacy receiver-turn suppressor and | ||
| 8 | * v2 interception, registration state, and synthetic event emission. | ||
| 9 | */ | ||
| 10 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 11 | import * as NodeFS from "node:fs"; | ||
| 12 | import * as NodePath from "node:path"; | ||
| 13 | |||
| 14 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 15 | import { it } from "@effect/vitest"; | ||
| 16 | import { ThreadId } from "@t3tools/contracts"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as Fiber from "effect/Fiber"; | ||
| 19 | import * as Stream from "effect/Stream"; | ||
| 20 | import { assert, describe } from "vite-plus/test"; | ||
| 21 | |||
| 22 | import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; | ||
| 23 | import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; | ||
| 24 | |||
| 25 | const ROOT = wireFixture.rootThreadId; | ||
| 26 | const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; | ||
| 27 | |||
| 28 | /** | ||
| 29 | * The captured sequence, extended with the shapes the live capture didn't | ||
| 30 | * include: a collabAgentToolCall with receiverThreadIds (feeds the legacy | ||
| 31 | * receiver-turn map, so ordering vs. v2 interception is exercised), child | ||
| 32 | * terminal lifecycle, and a serverRequest/resolved addressed to a child | ||
| 33 | * (must pass through to the parent path, not vanish). | ||
| 34 | */ | ||
| 35 | function buildScript() { | ||
| 36 | const captured = wireFixture.notifications; | ||
| 37 | const extras = [ | ||
| 38 | { | ||
| 39 | method: "item/completed", | ||
| 40 | params: { | ||
| 41 | threadId: ROOT, | ||
| 42 | item: { | ||
| 43 | type: "collabAgentToolCall", | ||
| 44 | id: "call_fixture_wait", | ||
| 45 | tool: "wait", | ||
| 46 | status: "completed", | ||
| 47 | senderThreadId: ROOT, | ||
| 48 | receiverThreadIds: [CHILD_A, CHILD_B], | ||
| 49 | }, | ||
| 50 | }, | ||
| 51 | }, | ||
| 52 | // Child terminal lifecycle AFTER the receiver map knows the children — | ||
| 53 | // pre-fix, the legacy suppressor dropped these before interception saw | ||
| 54 | // them, so no synthetic agent events were emitted. | ||
| 55 | { | ||
| 56 | method: "turn/completed", | ||
| 57 | params: { | ||
| 58 | threadId: CHILD_A, | ||
| 59 | turn: { id: `${CHILD_A}-turn-1`, status: "completed", items: [] }, | ||
| 60 | }, | ||
| 61 | }, | ||
| 62 | { method: "thread/closed", params: { threadId: CHILD_B } }, | ||
| 63 | // Parent-owned traffic addressed to a child conversation: must reach the | ||
| 64 | // parent path (approval correlation cleanup), not be swallowed. | ||
| 65 | { method: "serverRequest/resolved", params: { threadId: CHILD_A, requestId: "req-1" } }, | ||
| 66 | ]; | ||
| 67 | return { | ||
| 68 | rootThreadId: ROOT, | ||
| 69 | notifications: [...captured.filter((entry) => entry.method !== "turn/completed"), ...extras], | ||
| 70 | }; | ||
| 71 | } | ||
| 72 | |||
| 73 | const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); | ||
| 74 | const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh"); | ||
| 75 | |||
| 76 | describe("CodexSessionRuntime collab integration", () => { | ||
| 77 | it.effect("replays the captured fan-out into synthetic agent events without child leaks", () => | ||
| 78 | Effect.gen(function* () { | ||
| 79 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 80 | NodeFS.writeFileSync(scriptPath, JSON.stringify(buildScript()), "utf8"); | ||
| 81 | yield* Effect.addFinalizer(() => | ||
| 82 | Effect.sync(() => NodeFS.rmSync(scriptPath, { force: true })), | ||
| 83 | ); | ||
| 84 | |||
| 85 | const runtime = yield* makeCodexSessionRuntime({ | ||
| 86 | threadId: ThreadId.make("thread-collab-integration"), | ||
| 87 | binaryPath: peerPath, | ||
| 88 | cwd: "/tmp", | ||
| 89 | runtimeMode: "full-access", | ||
| 90 | environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, | ||
| 91 | }); | ||
| 92 | |||
| 93 | const eventsFiber = yield* runtime.events.pipe( | ||
| 94 | Stream.takeUntil((event) => event.method === "turn/completed"), | ||
| 95 | Stream.runCollect, | ||
| 96 | Effect.forkScoped, | ||
| 97 | ); | ||
| 98 | |||
| 99 | yield* runtime.start(); | ||
| 100 | yield* runtime.sendTurn({ input: "fan out" }); | ||
| 101 | |||
| 102 | const events = Array.from(yield* Fiber.join(eventsFiber)); | ||
| 103 | const methods = events.map((event) => event.method); | ||
| 104 | |||
| 105 | // Children registered from subAgentActivity become synthetic agent | ||
| 106 | // lifecycle — including terminal rows that arrive AFTER the receiver | ||
| 107 | // map knows them (the ordering this test exists to pin). | ||
| 108 | assert.include(methods, "collabAgent/activity"); | ||
| 109 | assert.include(methods, "collabAgent/turnCompleted"); | ||
| 110 | assert.include(methods, "collabAgent/closed"); | ||
| 111 | |||
| 112 | const childTurnCompleted = events.find( | ||
| 113 | (event) => | ||
| 114 | event.method === "collabAgent/turnCompleted" && | ||
| 115 | (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, | ||
| 116 | ); | ||
| 117 | assert.isDefined(childTurnCompleted, "child A's turn completion becomes an agent event"); | ||
| 118 | |||
| 119 | const childClosed = events.find( | ||
| 120 | (event) => | ||
| 121 | event.method === "collabAgent/closed" && | ||
| 122 | (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B, | ||
| 123 | ); | ||
| 124 | assert.isDefined(childClosed, "child B's close becomes an agent event"); | ||
| 125 | |||
| 126 | // Parent-owned resolution passes through — not swallowed, not | ||
| 127 | // re-labelled as an agent event. | ||
| 128 | assert.include(methods, "serverRequest/resolved"); | ||
| 129 | |||
| 130 | // The root's own subAgentActivity about "/root" must NOT register the | ||
| 131 | // root as a child: the parent turn completion still flows. | ||
| 132 | assert.include(methods, "turn/completed"); | ||
| 133 | |||
| 134 | // No raw child conversation methods leak onto the parent stream. | ||
| 135 | const leaked = events.filter((event) => { | ||
| 136 | const payload = event.payload as { threadId?: string } | undefined; | ||
| 137 | const addressedToChild = payload?.threadId === CHILD_A || payload?.threadId === CHILD_B; | ||
| 138 | return addressedToChild && (event.method?.startsWith("thread/") ?? false); | ||
| 139 | }); | ||
| 140 | assert.deepEqual( | ||
| 141 | leaked.map((event) => event.method), | ||
| 142 | [], | ||
| 143 | "child thread/* lifecycle must not appear as parent events", | ||
| 144 | ); | ||
| 145 | |||
| 146 | yield* runtime.close; | ||
| 147 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 148 | ); | ||
| 149 | |||
| 150 | // it.live: the runtime talks to a real child process; under it.effect's | ||
| 151 | // TestClock the internal timers freeze and the join never completes. | ||
| 152 | it.live("Stop interrupts every live child regardless of registration timing", () => | ||
| 153 | Effect.gen(function* () { | ||
| 154 | // Ordering + liveness torture for stop-everything: child A's | ||
| 155 | // turn/started arrives BEFORE anything registers it (foreign | ||
| 156 | // suppression path must record the live turn); child B's arrives after | ||
| 157 | // registration; child A's interrupt HANGS (RPC never settles — worse | ||
| 158 | // than rejecting) and the bounded deadline must still deliver B's and | ||
| 159 | // the parent's interrupts. The turn stays open so children are live | ||
| 160 | // when Stop fires. | ||
| 161 | // Build from REAL captured rows (hand-written shapes fail notification | ||
| 162 | // schema validation and are silently dropped): reorder so child A's | ||
| 163 | // turn/started precedes its registration, and drop terminal rows so | ||
| 164 | // children stay live when Stop fires. | ||
| 165 | const byIndex = wireFixture.notifications; | ||
| 166 | const isTurnStarted = (entry: (typeof byIndex)[number], child: string) => | ||
| 167 | entry.method === "turn/started" && | ||
| 168 | (entry.params as { threadId?: string }).threadId === child; | ||
| 169 | const isRegistration = (entry: (typeof byIndex)[number], child: string) => { | ||
| 170 | const item = (entry.params as { item?: { type?: string; agentThreadId?: string } }).item; | ||
| 171 | return item?.type === "subAgentActivity" && item.agentThreadId === child; | ||
| 172 | }; | ||
| 173 | const turnStartedA = byIndex.find((entry) => isTurnStarted(entry, CHILD_A)); | ||
| 174 | const turnStartedB = byIndex.find((entry) => isTurnStarted(entry, CHILD_B)); | ||
| 175 | const registrationA = byIndex.find((entry) => isRegistration(entry, CHILD_A)); | ||
| 176 | const registrationB = byIndex.find((entry) => isRegistration(entry, CHILD_B)); | ||
| 177 | assert.isDefined(turnStartedA); | ||
| 178 | assert.isDefined(turnStartedB); | ||
| 179 | assert.isDefined(registrationA); | ||
| 180 | assert.isDefined(registrationB); | ||
| 181 | const script = { | ||
| 182 | rootThreadId: ROOT, | ||
| 183 | holdTurnOpen: true, | ||
| 184 | hangInterruptFor: CHILD_A, | ||
| 185 | notifications: [turnStartedA, registrationA, registrationB, turnStartedB], | ||
| 186 | }; | ||
| 187 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 188 | NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); | ||
| 189 | const interruptsPath = `${scriptPath}.interrupts`; | ||
| 190 | NodeFS.rmSync(interruptsPath, { force: true }); | ||
| 191 | yield* Effect.addFinalizer(() => | ||
| 192 | Effect.sync(() => { | ||
| 193 | NodeFS.rmSync(scriptPath, { force: true }); | ||
| 194 | NodeFS.rmSync(interruptsPath, { force: true }); | ||
| 195 | }), | ||
| 196 | ); | ||
| 197 | |||
| 198 | const runtime = yield* makeCodexSessionRuntime({ | ||
| 199 | threadId: ThreadId.make("thread-collab-stop"), | ||
| 200 | binaryPath: peerPath, | ||
| 201 | cwd: "/tmp", | ||
| 202 | runtimeMode: "full-access", | ||
| 203 | environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, | ||
| 204 | }); | ||
| 205 | |||
| 206 | // Wait for both children's turnStarted signals to be processed before | ||
| 207 | // stopping (B via the registered-child path; A only produces live-turn | ||
| 208 | // bookkeeping, so key on B's synthetic event). | ||
| 209 | const childBStartedFiber = yield* runtime.events.pipe( | ||
| 210 | Stream.filter( | ||
| 211 | (event) => | ||
| 212 | event.method === "collabAgent/turnStarted" && | ||
| 213 | (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B, | ||
| 214 | ), | ||
| 215 | Stream.take(1), | ||
| 216 | Stream.runCollect, | ||
| 217 | Effect.forkScoped, | ||
| 218 | ); | ||
| 219 | |||
| 220 | yield* runtime.start(); | ||
| 221 | yield* runtime.sendTurn({ input: "fan out and hang" }); | ||
| 222 | const childBStarted = yield* Fiber.join(childBStartedFiber).pipe( | ||
| 223 | Effect.timeoutOption("15 seconds"), | ||
| 224 | ); | ||
| 225 | assert.isTrue(childBStarted._tag === "Some", "child B turnStarted never arrived"); | ||
| 226 | |||
| 227 | // Stop everything. A's interrupt hangs forever — the bounded child | ||
| 228 | // deadline must expire and the parent interrupt must still be sent. | ||
| 229 | yield* runtime.interruptTurn(); | ||
| 230 | |||
| 231 | const parseInterruptLine = (line: string) => JSON.parse(line) as { threadId?: string }; | ||
| 232 | const interrupted = NodeFS.readFileSync(interruptsPath, "utf8") | ||
| 233 | .trim() | ||
| 234 | .split("\n") | ||
| 235 | .filter((line) => line.length > 0) | ||
| 236 | .map(parseInterruptLine); | ||
| 237 | const interruptedThreads = new Set(interrupted.map((entry) => entry.threadId)); | ||
| 238 | assert.isTrue( | ||
| 239 | interruptedThreads.has(CHILD_A), | ||
| 240 | "pre-registration child A must still receive the interrupt RPC", | ||
| 241 | ); | ||
| 242 | assert.isTrue(interruptedThreads.has(CHILD_B), "registered child B must be interrupted"); | ||
| 243 | assert.isTrue(interruptedThreads.has(ROOT), "parent turn must be interrupted last"); | ||
| 244 | |||
| 245 | yield* runtime.close; | ||
| 246 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 247 | ); | ||
| 248 | }); | ||
apps/server/src/provider/Layers/CodexCollabWire.test.ts created+180| ... | @@ -0,0 +1,180 @@ | ||
| 1 | /** | ||
| 2 | * Codex multi-agent wire fixtures. | ||
| 3 | * | ||
| 4 | * The child-interception path is the highest-blast-radius code in the | ||
| 5 | * subagent stack: it decides, per notification, whether traffic reaches the | ||
| 6 | * parent timeline at all. Three shipped bugs came from that decision being | ||
| 7 | * implicit (root thread registered as its own child; `error` and | ||
| 8 | * `serverRequest/resolved` swallowed by a catch-all). These tests pin the | ||
| 9 | * decision against a REAL capture rather than hand-written shapes. | ||
| 10 | * | ||
| 11 | * Fixture provenance: codex-cli 0.145.0 driven directly over stdio with | ||
| 12 | * gpt-5.6-luna at low effort, prompting a two-child fan-out (alpha, beta). | ||
| 13 | * See codexMultiAgentWire.json. | ||
| 14 | */ | ||
| 15 | import { assert, describe, it } from "vite-plus/test"; | ||
| 16 | |||
| 17 | import fixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; | ||
| 18 | import { routeCodexChildNotification } from "./CodexSessionRuntime.ts"; | ||
| 19 | |||
| 20 | interface WireNotification { | ||
| 21 | readonly method: string; | ||
| 22 | readonly params: Record<string, unknown>; | ||
| 23 | } | ||
| 24 | |||
| 25 | const notifications = fixture.notifications as ReadonlyArray<WireNotification>; | ||
| 26 | const rootThreadId = fixture.rootThreadId; | ||
| 27 | const childThreadIds = new Set(fixture.childThreadIds); | ||
| 28 | |||
| 29 | /** Mirrors readNotificationThreadId's addressing for the captured methods. */ | ||
| 30 | function notificationThreadId(entry: WireNotification): string | undefined { | ||
| 31 | const params = entry.params; | ||
| 32 | const thread = params.thread; | ||
| 33 | if ( | ||
| 34 | typeof thread === "object" && | ||
| 35 | thread !== null && | ||
| 36 | typeof (thread as { id?: unknown }).id === "string" | ||
| 37 | ) { | ||
| 38 | return (thread as { id: string }).id; | ||
| 39 | } | ||
| 40 | return typeof params.threadId === "string" ? params.threadId : undefined; | ||
| 41 | } | ||
| 42 | |||
| 43 | function subAgentActivityItems(): ReadonlyArray<Record<string, unknown>> { | ||
| 44 | return notifications.flatMap((entry) => { | ||
| 45 | const item = entry.params.item; | ||
| 46 | if (typeof item !== "object" || item === null) return []; | ||
| 47 | const record = item as Record<string, unknown>; | ||
| 48 | return record.type === "subAgentActivity" ? [record] : []; | ||
| 49 | }); | ||
| 50 | } | ||
| 51 | |||
| 52 | describe("codex multi-agent wire capture", () => { | ||
| 53 | it("captures a real two-child fan-out", () => { | ||
| 54 | assert.equal(fixture.capturedWith.model, "gpt-5.6-luna"); | ||
| 55 | assert.equal(childThreadIds.size, 2); | ||
| 56 | const paths = subAgentActivityItems().map((item) => item.agentPath); | ||
| 57 | assert.include(paths, "/root/alpha"); | ||
| 58 | assert.include(paths, "/root/beta"); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("emits child traffic BEFORE the item that registers the child", () => { | ||
| 62 | // Ordering hazard: the child's own thread/status/changed arrives before | ||
| 63 | // the parent-side subAgentActivity naming it. Registration must tolerate | ||
| 64 | // child-first arrival, so unregistered child traffic passes through | ||
| 65 | // rather than being eaten (no regression vs. pre-feature behavior). | ||
| 66 | const firstChildTraffic = notifications.findIndex((entry) => { | ||
| 67 | const threadId = notificationThreadId(entry); | ||
| 68 | return threadId !== undefined && childThreadIds.has(threadId); | ||
| 69 | }); | ||
| 70 | const firstRegistration = notifications.findIndex((entry) => { | ||
| 71 | const item = entry.params.item; | ||
| 72 | if (typeof item !== "object" || item === null) return false; | ||
| 73 | const record = item as Record<string, unknown>; | ||
| 74 | return record.type === "subAgentActivity" && record.kind === "started"; | ||
| 75 | }); | ||
| 76 | assert.isAtLeast(firstChildTraffic, 0); | ||
| 77 | assert.isAtLeast(firstRegistration, 0); | ||
| 78 | assert.isBelow( | ||
| 79 | firstChildTraffic, | ||
| 80 | firstRegistration, | ||
| 81 | "capture should exercise child-first ordering", | ||
| 82 | ); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("contains a /root self-activity emitted from a CHILD thread", () => { | ||
| 86 | // The bug this guards: the wire reports subAgentActivity about the ROOT | ||
| 87 | // (agentPath "/root"). Registering it made the runtime intercept the | ||
| 88 | // parent's own final message and turn/completed, so the thread hung | ||
| 89 | // "working" forever. The root guard must key on agentPath/thread id, | ||
| 90 | // never on which thread the notification arrived from. | ||
| 91 | const rootSelfActivity = subAgentActivityItems().find((item) => item.agentPath === "/root"); | ||
| 92 | assert.isDefined(rootSelfActivity, "capture should contain a /root self-activity"); | ||
| 93 | assert.equal(rootSelfActivity?.agentThreadId, rootThreadId); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("routes every captured child method to a defined disposition", () => { | ||
| 97 | const childMethods = new Set( | ||
| 98 | notifications | ||
| 99 | .filter((entry) => { | ||
| 100 | const threadId = notificationThreadId(entry); | ||
| 101 | return threadId !== undefined && childThreadIds.has(threadId); | ||
| 102 | }) | ||
| 103 | .map((entry) => entry.method), | ||
| 104 | ); | ||
| 105 | assert.isAbove(childMethods.size, 0); | ||
| 106 | for (const method of childMethods) { | ||
| 107 | const route = routeCodexChildNotification(method); | ||
| 108 | // Child lifecycle traffic must become agent events — never silently | ||
| 109 | // dropped, never leaked to the parent timeline. | ||
| 110 | assert.equal(route, "agent-event", `${method} should map to an agent event`); | ||
| 111 | } | ||
| 112 | }); | ||
| 113 | }); | ||
| 114 | |||
| 115 | describe("routeCodexChildNotification", () => { | ||
| 116 | it("maps child lifecycle to agent events", () => { | ||
| 117 | for (const method of [ | ||
| 118 | "turn/started", | ||
| 119 | "turn/completed", | ||
| 120 | "thread/status/changed", | ||
| 121 | "thread/tokenUsage/updated", | ||
| 122 | "item/started", | ||
| 123 | "item/completed", | ||
| 124 | "thread/closed", | ||
| 125 | "error", | ||
| 126 | ]) { | ||
| 127 | assert.equal(routeCodexChildNotification(method), "agent-event", method); | ||
| 128 | } | ||
| 129 | }); | ||
| 130 | |||
| 131 | it("drops only enumerated child chatter", () => { | ||
| 132 | for (const method of [ | ||
| 133 | "item/agentMessage/delta", | ||
| 134 | "item/reasoning/textDelta", | ||
| 135 | "item/commandExecution/outputDelta", | ||
| 136 | "turn/plan/updated", | ||
| 137 | "thread/name/updated", | ||
| 138 | ]) { | ||
| 139 | assert.equal(routeCodexChildNotification(method), "drop", method); | ||
| 140 | } | ||
| 141 | }); | ||
| 142 | |||
| 143 | it("never routes child-owned thread lifecycle to the parent", () => { | ||
| 144 | // These mutate PARENT thread state in CodexAdapter (archived/compacted), | ||
| 145 | // so a child emitting them must never reach the parent path. This list | ||
| 146 | // mirrors shouldSuppressChildConversationNotification (the v1 collab | ||
| 147 | // suppressor) — the two must not drift (review finding: the router | ||
| 148 | // initially omitted them and they leaked). | ||
| 149 | for (const method of [ | ||
| 150 | "thread/started", | ||
| 151 | "thread/status/changed", | ||
| 152 | "thread/archived", | ||
| 153 | "thread/unarchived", | ||
| 154 | "thread/closed", | ||
| 155 | "thread/compacted", | ||
| 156 | "thread/name/updated", | ||
| 157 | "thread/tokenUsage/updated", | ||
| 158 | "turn/started", | ||
| 159 | "turn/completed", | ||
| 160 | "turn/plan/updated", | ||
| 161 | "item/plan/delta", | ||
| 162 | ]) { | ||
| 163 | assert.notEqual( | ||
| 164 | routeCodexChildNotification(method), | ||
| 165 | "parent", | ||
| 166 | `${method} is child-owned and must not reach the parent path`, | ||
| 167 | ); | ||
| 168 | } | ||
| 169 | }); | ||
| 170 | |||
| 171 | it("sends parent-owned and UNKNOWN methods to the parent path", () => { | ||
| 172 | // serverRequest/resolved clears the parent's approval correlation: | ||
| 173 | // swallowing it left approvals stuck (shipped bug). Unknown methods take | ||
| 174 | // the same route by design — a codex update that adds a notification | ||
| 175 | // must degrade to "parent sees it", never to silent loss. | ||
| 176 | assert.equal(routeCodexChildNotification("serverRequest/resolved"), "parent"); | ||
| 177 | assert.equal(routeCodexChildNotification("thread/somethingBrandNew"), "parent"); | ||
| 178 | assert.equal(routeCodexChildNotification("account/rateLimits/updated"), "parent"); | ||
| 179 | }); | ||
| 180 | }); | ||
apps/server/src/provider/Layers/CodexSessionRuntime.ts+490-1| ... | @@ -600,6 +600,71 @@ function readRouteFields(notification: CodexServerNotification): { | ... | @@ -600,6 +600,71 @@ function readRouteFields(notification: CodexServerNotification): { |
| 600 | } | 600 | } |
| 601 | } | 601 | } |
| 602 | 602 | ||
| 603 | /** | ||
| 604 | * Native collab child-agent tracking (multi-agent v2). Under v2 subagents are | ||
| 605 | * full app-server threads: identity arrives on `thread/started` with | ||
| 606 | * source.subAgent.thread_spawn, lifecycle on `subAgentActivity` items and the | ||
| 607 | * child thread's own turn/status/tokenUsage notifications. The runtime | ||
| 608 | * registers children from those explicit signals, intercepts their | ||
| 609 | * notifications before parent-timeline mapping, and re-emits them as | ||
| 610 | * synthetic `collabAgent/*` provider events the adapter turns into task.* | ||
| 611 | * runtime events (timelineBypass keeps them out of the parent chat). | ||
| 612 | * | ||
| 613 | * WIP, probe-gated: registration is deliberately explicit-signals-only. The | ||
| 614 | * spec's "provisionally treat unknown foreign thread ids as v2 children" rule | ||
| 615 | * needs a live wire capture of the packaged binary before it lands — blind | ||
| 616 | * capture risks eating unrelated traffic. Until then a child whose first | ||
| 617 | * notification precedes registration passes through as today (no regression | ||
| 618 | * vs main, which passes everything through). | ||
| 619 | */ | ||
| 620 | interface CollabChildAgentState { | ||
| 621 | readonly agentThreadId: string; | ||
| 622 | readonly nickname: string | undefined; | ||
| 623 | readonly role: string | undefined; | ||
| 624 | readonly agentPath: string | undefined; | ||
| 625 | readonly depth: number | undefined; | ||
| 626 | readonly parentThreadId: string | undefined; | ||
| 627 | /** | ||
| 628 | * Parent canonical turn active when the child registered. Stamped on every | ||
| 629 | * synthetic collabAgent/* event so clients can batch a fleet by its spawn | ||
| 630 | * turn — without it, separate fleets in one thread collapsed into a single | ||
| 631 | * "direct:no-turn" CTA (review finding). | ||
| 632 | */ | ||
| 633 | readonly spawnTurnId: TurnId | undefined; | ||
| 634 | } | ||
| 635 | |||
| 636 | function readThreadSpawnSource(thread: { readonly source: unknown }): | ||
| 637 | | { | ||
| 638 | nickname: string | undefined; | ||
| 639 | role: string | undefined; | ||
| 640 | agentPath: string | undefined; | ||
| 641 | depth: number | undefined; | ||
| 642 | parentThreadId: string | undefined; | ||
| 643 | } | ||
| 644 | | undefined { | ||
| 645 | const source = thread.source; | ||
| 646 | if (typeof source !== "object" || source === null || !("subAgent" in source)) { | ||
| 647 | return undefined; | ||
| 648 | } | ||
| 649 | const subAgent = (source as { subAgent: unknown }).subAgent; | ||
| 650 | if (typeof subAgent !== "object" || subAgent === null || !("thread_spawn" in subAgent)) { | ||
| 651 | return undefined; | ||
| 652 | } | ||
| 653 | const spawn = (subAgent as { thread_spawn: unknown }).thread_spawn; | ||
| 654 | if (typeof spawn !== "object" || spawn === null) { | ||
| 655 | return undefined; | ||
| 656 | } | ||
| 657 | const record = spawn as Record<string, unknown>; | ||
| 658 | return { | ||
| 659 | nickname: typeof record.agent_nickname === "string" ? record.agent_nickname : undefined, | ||
| 660 | role: typeof record.agent_role === "string" ? record.agent_role : undefined, | ||
| 661 | agentPath: typeof record.agent_path === "string" ? record.agent_path : undefined, | ||
| 662 | depth: typeof record.depth === "number" ? record.depth : undefined, | ||
| 663 | parentThreadId: | ||
| 664 | typeof record.parent_thread_id === "string" ? record.parent_thread_id : undefined, | ||
| 665 | }; | ||
| 666 | } | ||
| 667 | |||
| 603 | function rememberCollabReceiverTurns( | 668 | function rememberCollabReceiverTurns( |
| 604 | collabReceiverTurns: Map<string, TurnId>, | 669 | collabReceiverTurns: Map<string, TurnId>, |
| 605 | notification: CodexServerNotification, | 670 | notification: CodexServerNotification, |
| ... | @@ -641,6 +706,72 @@ function shouldSuppressChildConversationNotification( | ... | @@ -641,6 +706,72 @@ function shouldSuppressChildConversationNotification( |
| 641 | ); | 706 | ); |
| 642 | } | 707 | } |
| 643 | 708 | ||
| 709 | /** | ||
| 710 | * How a notification addressed to a REGISTERED child thread is handled. | ||
| 711 | * | ||
| 712 | * Exported and pure so the routing table can be asserted against captured | ||
| 713 | * wire traces (see codexMultiAgentWire.json) rather than only read. | ||
| 714 | * | ||
| 715 | * - "agent-event": map to a synthetic collabAgent/* event (Agents surface). | ||
| 716 | * - "parent": pass through to the parent path — it carries state the parent | ||
| 717 | * still owns (approval correlation cleanup). | ||
| 718 | * - "drop": genuine child chatter with no parent meaning (deltas, name and | ||
| 719 | * plan updates). | ||
| 720 | * | ||
| 721 | * Default is "drop" ONLY for the enumerated chatter; anything unrecognized | ||
| 722 | * routes to "parent" so new wire methods surface instead of vanishing | ||
| 723 | * (two shipped bugs came from a catch-all that swallowed everything). | ||
| 724 | */ | ||
| 725 | export type CodexChildNotificationRoute = "agent-event" | "parent" | "drop"; | ||
| 726 | |||
| 727 | const CHILD_AGENT_EVENT_METHODS: ReadonlySet<string> = new Set([ | ||
| 728 | "turn/started", | ||
| 729 | "turn/completed", | ||
| 730 | "thread/status/changed", | ||
| 731 | "thread/tokenUsage/updated", | ||
| 732 | "item/started", | ||
| 733 | "item/completed", | ||
| 734 | "thread/closed", | ||
| 735 | "error", | ||
| 736 | ]); | ||
| 737 | |||
| 738 | const CHILD_CHATTER_METHODS: ReadonlySet<string> = new Set([ | ||
| 739 | "item/agentMessage/delta", | ||
| 740 | "item/reasoning/textDelta", | ||
| 741 | "item/reasoning/summaryTextDelta", | ||
| 742 | "item/reasoning/summaryPartAdded", | ||
| 743 | "item/commandExecution/outputDelta", | ||
| 744 | "item/fileChange/outputDelta", | ||
| 745 | "item/fileChange/patchUpdated", | ||
| 746 | "item/plan/delta", | ||
| 747 | "turn/plan/updated", | ||
| 748 | "turn/diff/updated", | ||
| 749 | "thread/name/updated", | ||
| 750 | "thread/settings/updated", | ||
| 751 | "rawResponseItem/completed", | ||
| 752 | // Child-owned thread lifecycle: the parent adapter maps these onto the | ||
| 753 | // PARENT thread (archived/compacted state), so a child compacting would | ||
| 754 | // rewrite the parent. Mirrors the v1 suppressor list — dropping them is | ||
| 755 | // the pre-existing behavior for collab children (review finding). | ||
| 756 | "thread/archived", | ||
| 757 | "thread/unarchived", | ||
| 758 | "thread/compacted", | ||
| 759 | // Registration path 1 handles a child's first thread/started; a repeat | ||
| 760 | // must not reach the parent (it would restart the parent's thread state). | ||
| 761 | "thread/started", | ||
| 762 | ]); | ||
| 763 | |||
| 764 | export function routeCodexChildNotification(method: string): CodexChildNotificationRoute { | ||
| 765 | if (CHILD_AGENT_EVENT_METHODS.has(method)) { | ||
| 766 | return "agent-event"; | ||
| 767 | } | ||
| 768 | if (CHILD_CHATTER_METHODS.has(method)) { | ||
| 769 | return "drop"; | ||
| 770 | } | ||
| 771 | // Unknown or parent-owned (serverRequest/resolved, approvals, …). | ||
| 772 | return "parent"; | ||
| 773 | } | ||
| 774 | |||
| 644 | function toCodexUserInputAnswer( | 775 | function toCodexUserInputAnswer( |
| 645 | questionId: string, | 776 | questionId: string, |
| 646 | value: ProviderUserInputAnswers[string], | 777 | value: ProviderUserInputAnswers[string], |
| ... | @@ -723,6 +854,9 @@ export const makeCodexSessionRuntime = ( | ... | @@ -723,6 +854,9 @@ export const makeCodexSessionRuntime = ( |
| 723 | const approvalCorrelationsRef = yield* Ref.make(new Map<string, ApprovalCorrelation>()); | 854 | const approvalCorrelationsRef = yield* Ref.make(new Map<string, ApprovalCorrelation>()); |
| 724 | const pendingUserInputsRef = yield* Ref.make(new Map<ApprovalRequestId, PendingUserInput>()); | 855 | const pendingUserInputsRef = yield* Ref.make(new Map<ApprovalRequestId, PendingUserInput>()); |
| 725 | const collabReceiverTurnsRef = yield* Ref.make(new Map<string, TurnId>()); | 856 | const collabReceiverTurnsRef = yield* Ref.make(new Map<string, TurnId>()); |
| 857 | const collabChildAgentsRef = yield* Ref.make(new Map<string, CollabChildAgentState>()); | ||
| 858 | /** Child provider-thread id → its currently running provider turn id. */ | ||
| 859 | const collabChildLiveTurnsRef = yield* Ref.make(new Map<string, string>()); | ||
| 726 | const closedRef = yield* Ref.make(false); | 860 | const closedRef = yield* Ref.make(false); |
| 727 | 861 | ||
| 728 | // `~` is not shell-expanded when env vars are set via | 862 | // `~` is not shell-expanded when env vars are set via |
| ... | @@ -839,6 +973,280 @@ export const makeCodexSessionRuntime = ( | ... | @@ -839,6 +973,280 @@ export const makeCodexSessionRuntime = ( |
| 839 | ), | 973 | ), |
| 840 | ); | 974 | ); |
| 841 | 975 | ||
| 976 | /** | ||
| 977 | * Registers v2 collab children and re-emits their notifications as | ||
| 978 | * synthetic `collabAgent/*` events for the adapter's task.* synthesis. | ||
| 979 | * Returns true when the notification was fully handled (must not reach | ||
| 980 | * parent-timeline mapping). | ||
| 981 | */ | ||
| 982 | const interceptCollabChildNotification = (notification: CodexServerNotification) => | ||
| 983 | Effect.gen(function* () { | ||
| 984 | // Registration path 1: child thread announces itself with a | ||
| 985 | // subAgent thread_spawn source. | ||
| 986 | if (notification.method === "thread/started") { | ||
| 987 | const thread = notification.params.thread; | ||
| 988 | const spawn = readThreadSpawnSource(thread); | ||
| 989 | if (!spawn) { | ||
| 990 | return false; | ||
| 991 | } | ||
| 992 | // Merge with any subAgentActivity registration that got here | ||
| 993 | // first. spawnTurnId is REGISTRATION-time-only on both paths: for | ||
| 994 | // an already-known child we keep its value (set or unset) — a | ||
| 995 | // later thread/started during an unrelated parent turn must not | ||
| 996 | // backfill that turn as the spawn batch, which would stamp an old | ||
| 997 | // child onto a new fleet's CTA (review finding). Only a genuinely | ||
| 998 | // new registration captures the current turn. | ||
| 999 | const existingChild = (yield* Ref.get(collabChildAgentsRef)).get(thread.id); | ||
| 1000 | const spawnTurnId = existingChild | ||
| 1001 | ? existingChild.spawnTurnId | ||
| 1002 | : ((yield* Ref.get(sessionRef)).activeTurnId ?? undefined); | ||
| 1003 | const state: CollabChildAgentState = { | ||
| 1004 | agentThreadId: thread.id, | ||
| 1005 | nickname: spawn.nickname ?? thread.agentNickname ?? existingChild?.nickname, | ||
| 1006 | role: spawn.role ?? thread.agentRole ?? existingChild?.role, | ||
| 1007 | agentPath: spawn.agentPath ?? existingChild?.agentPath, | ||
| 1008 | depth: spawn.depth ?? existingChild?.depth, | ||
| 1009 | parentThreadId: | ||
| 1010 | spawn.parentThreadId ?? thread.parentThreadId ?? existingChild?.parentThreadId, | ||
| 1011 | spawnTurnId, | ||
| 1012 | }; | ||
| 1013 | yield* Ref.update(collabChildAgentsRef, (current) => { | ||
| 1014 | const next = new Map(current); | ||
| 1015 | next.set(thread.id, state); | ||
| 1016 | return next; | ||
| 1017 | }); | ||
| 1018 | yield* emitEvent({ | ||
| 1019 | kind: "notification", | ||
| 1020 | threadId: options.threadId, | ||
| 1021 | method: "collabAgent/started", | ||
| 1022 | ...(state.spawnTurnId ? { turnId: state.spawnTurnId } : {}), | ||
| 1023 | payload: { | ||
| 1024 | agentThreadId: state.agentThreadId, | ||
| 1025 | ...(state.nickname ? { nickname: state.nickname } : {}), | ||
| 1026 | ...(state.role ? { role: state.role } : {}), | ||
| 1027 | ...(state.agentPath ? { agentPath: state.agentPath } : {}), | ||
| 1028 | ...(state.depth !== undefined ? { depth: state.depth } : {}), | ||
| 1029 | ...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}), | ||
| 1030 | }, | ||
| 1031 | }); | ||
| 1032 | return true; | ||
| 1033 | } | ||
| 1034 | |||
| 1035 | // Registration path 2: parent-side subAgentActivity item names the | ||
| 1036 | // child thread (may arrive before or after thread/started). | ||
| 1037 | if ( | ||
| 1038 | (notification.method === "item/started" || notification.method === "item/completed") && | ||
| 1039 | notification.params.item.type === "subAgentActivity" | ||
| 1040 | ) { | ||
| 1041 | const item = notification.params.item; | ||
| 1042 | // Never register the session's ROOT thread as its own child. The | ||
| 1043 | // wire emits subAgentActivity {agentPath: "/root", interacted} | ||
| 1044 | // about the root during collab runs; registering it intercepted | ||
| 1045 | // every subsequent root notification — including the final | ||
| 1046 | // assistant message and turn/completed — so the thread hung | ||
| 1047 | // "working" after all subagents finished (live-probe finding). | ||
| 1048 | const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); | ||
| 1049 | if ( | ||
| 1050 | item.agentThreadId === rootProviderThreadId || | ||
| 1051 | item.agentPath === "/root" || | ||
| 1052 | item.agentPath === "/" | ||
| 1053 | ) { | ||
| 1054 | return false; | ||
| 1055 | } | ||
| 1056 | const activitySpawnTurnId = (yield* Ref.get(sessionRef)).activeTurnId ?? undefined; | ||
| 1057 | yield* Ref.update(collabChildAgentsRef, (current) => { | ||
| 1058 | const existing = current.get(item.agentThreadId); | ||
| 1059 | const next = new Map(current); | ||
| 1060 | // Merge-late semantics: when thread/started registered first, a | ||
| 1061 | // later subAgentActivity still carries the real agentPath (and a | ||
| 1062 | // derived nickname) — fill missing fields, never clobber known | ||
| 1063 | // ones. spawnTurnId is registration-time-only: for an already | ||
| 1064 | // registered child, a later activity during an UNRELATED turn | ||
| 1065 | // must not backfill that turn as the spawn batch (review | ||
| 1066 | // finding); an unset spawn turn stays unset. | ||
| 1067 | next.set(item.agentThreadId, { | ||
| 1068 | agentThreadId: item.agentThreadId, | ||
| 1069 | nickname: | ||
| 1070 | existing?.nickname ?? | ||
| 1071 | item.agentPath.split("/").findLast((segment) => segment.length > 0), | ||
| 1072 | role: existing?.role, | ||
| 1073 | agentPath: existing?.agentPath ?? item.agentPath, | ||
| 1074 | depth: existing?.depth, | ||
| 1075 | parentThreadId: existing?.parentThreadId, | ||
| 1076 | spawnTurnId: existing ? existing.spawnTurnId : activitySpawnTurnId, | ||
| 1077 | }); | ||
| 1078 | return next; | ||
| 1079 | }); | ||
| 1080 | const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId); | ||
| 1081 | yield* emitEvent({ | ||
| 1082 | kind: "notification", | ||
| 1083 | threadId: options.threadId, | ||
| 1084 | method: "collabAgent/activity", | ||
| 1085 | ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}), | ||
| 1086 | payload: { | ||
| 1087 | agentThreadId: item.agentThreadId, | ||
| 1088 | agentPath: item.agentPath, | ||
| 1089 | activityKind: item.kind, | ||
| 1090 | }, | ||
| 1091 | }); | ||
| 1092 | return true; | ||
| 1093 | } | ||
| 1094 | |||
| 1095 | // Interception: notifications addressed to a registered child thread | ||
| 1096 | // become agent-scoped synthetic events instead of parent chatter. | ||
| 1097 | const providerConversationId = readNotificationThreadId(notification); | ||
| 1098 | if (!providerConversationId) { | ||
| 1099 | return false; | ||
| 1100 | } | ||
| 1101 | // Belt-and-braces: the root thread's traffic must never be | ||
| 1102 | // intercepted, whatever the registry says. | ||
| 1103 | const interceptRootId = currentProviderThreadId(yield* Ref.get(sessionRef)); | ||
| 1104 | if (providerConversationId === interceptRootId) { | ||
| 1105 | return false; | ||
| 1106 | } | ||
| 1107 | const children = yield* Ref.get(collabChildAgentsRef); | ||
| 1108 | const child = children.get(providerConversationId); | ||
| 1109 | if (!child) { | ||
| 1110 | return false; | ||
| 1111 | } | ||
| 1112 | const childIdentity = { | ||
| 1113 | agentThreadId: child.agentThreadId, | ||
| 1114 | ...(child.nickname ? { nickname: child.nickname } : {}), | ||
| 1115 | ...(child.role ? { role: child.role } : {}), | ||
| 1116 | ...(child.agentPath ? { agentPath: child.agentPath } : {}), | ||
| 1117 | }; | ||
| 1118 | switch (notification.method) { | ||
| 1119 | case "turn/started": { | ||
| 1120 | const childTurnId = | ||
| 1121 | typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" | ||
| 1122 | ? ((notification.params as { turn: { id: string } }).turn.id as string) | ||
| 1123 | : undefined; | ||
| 1124 | if (childTurnId) { | ||
| 1125 | yield* Ref.update(collabChildLiveTurnsRef, (current) => { | ||
| 1126 | const next = new Map(current); | ||
| 1127 | next.set(child.agentThreadId, childTurnId); | ||
| 1128 | return next; | ||
| 1129 | }); | ||
| 1130 | } | ||
| 1131 | yield* emitEvent({ | ||
| 1132 | kind: "notification", | ||
| 1133 | threadId: options.threadId, | ||
| 1134 | ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), | ||
| 1135 | method: "collabAgent/turnStarted", | ||
| 1136 | payload: childIdentity, | ||
| 1137 | }); | ||
| 1138 | return true; | ||
| 1139 | } | ||
| 1140 | case "turn/completed": | ||
| 1141 | yield* Ref.update(collabChildLiveTurnsRef, (current) => { | ||
| 1142 | const next = new Map(current); | ||
| 1143 | next.delete(child.agentThreadId); | ||
| 1144 | return next; | ||
| 1145 | }); | ||
| 1146 | yield* emitEvent({ | ||
| 1147 | kind: "notification", | ||
| 1148 | threadId: options.threadId, | ||
| 1149 | ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), | ||
| 1150 | method: "collabAgent/turnCompleted", | ||
| 1151 | payload: { | ||
| 1152 | ...childIdentity, | ||
| 1153 | turn: notification.params.turn, | ||
| 1154 | }, | ||
| 1155 | }); | ||
| 1156 | return true; | ||
| 1157 | case "thread/status/changed": | ||
| 1158 | yield* emitEvent({ | ||
| 1159 | kind: "notification", | ||
| 1160 | threadId: options.threadId, | ||
| 1161 | ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), | ||
| 1162 | method: "collabAgent/statusChanged", | ||
| 1163 | payload: { | ||
| 1164 | ...childIdentity, | ||
| 1165 | status: notification.params.status, | ||
| 1166 | }, | ||
| 1167 | }); | ||
| 1168 | return true; | ||
| 1169 | case "thread/tokenUsage/updated": | ||
| 1170 | yield* emitEvent({ | ||
| 1171 | kind: "notification", | ||
| 1172 | threadId: options.threadId, | ||
| 1173 | ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), | ||
| 1174 | method: "collabAgent/tokenUsage", | ||
| 1175 | payload: { | ||
| 1176 | ...childIdentity, | ||
| 1177 | tokenUsage: notification.params.tokenUsage, | ||
| 1178 | }, | ||
| 1179 | }); | ||
| 1180 | return true; | ||
| 1181 | case "item/started": | ||
| 1182 | case "item/completed": | ||
| 1183 | yield* emitEvent({ | ||
| 1184 | kind: "notification", | ||
| 1185 | threadId: options.threadId, | ||
| 1186 | ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), | ||
| 1187 | method: "collabAgent/item", | ||
| 1188 | payload: { | ||
| 1189 | ...childIdentity, | ||
| 1190 | item: notification.params.item, | ||
| 1191 | }, | ||
| 1192 | }); | ||
| 1193 | return true; | ||
| 1194 | case "thread/closed": | ||
| 1195 | // The child is gone: drop its live-turn entry so a later Stop | ||
| 1196 | // doesn't waste a turn/interrupt RPC on a closed thread before | ||
| 1197 | // reaching the parent (review finding). | ||
| 1198 | yield* Ref.update(collabChildLiveTurnsRef, (current) => { | ||
| 1199 | const next = new Map(current); | ||
| 1200 | next.delete(child.agentThreadId); | ||
| 1201 | return next; | ||
| 1202 | }); | ||
| 1203 | yield* emitEvent({ | ||
| 1204 | kind: "notification", | ||
| 1205 | threadId: options.threadId, | ||
| 1206 | ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), | ||
| 1207 | method: "collabAgent/closed", | ||
| 1208 | payload: childIdentity, | ||
| 1209 | }); | ||
| 1210 | return true; | ||
| 1211 | case "error": { | ||
| 1212 | // A child error must surface as a failed agent, not vanish into | ||
| 1213 | // the default swallow (review finding: the child stayed | ||
| 1214 | // "running" forever). Retryable errors (willRetry) keep the | ||
| 1215 | // child RUNNING and interruptible — mirroring the root error | ||
| 1216 | // handler; settling it would orphan a still-live child from | ||
| 1217 | // Stop (review finding). Terminal errors clean up the live turn | ||
| 1218 | // like thread/closed and reuse the statusChanged systemError | ||
| 1219 | // path. | ||
| 1220 | const willRetry = (notification.params as { willRetry?: boolean }).willRetry === true; | ||
| 1221 | if (willRetry) { | ||
| 1222 | return true; | ||
| 1223 | } | ||
| 1224 | yield* Ref.update(collabChildLiveTurnsRef, (current) => { | ||
| 1225 | const next = new Map(current); | ||
| 1226 | next.delete(child.agentThreadId); | ||
| 1227 | return next; | ||
| 1228 | }); | ||
| 1229 | yield* emitEvent({ | ||
| 1230 | kind: "notification", | ||
| 1231 | threadId: options.threadId, | ||
| 1232 | ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), | ||
| 1233 | method: "collabAgent/statusChanged", | ||
| 1234 | payload: { | ||
| 1235 | ...childIdentity, | ||
| 1236 | status: { type: "systemError" }, | ||
| 1237 | }, | ||
| 1238 | }); | ||
| 1239 | return true; | ||
| 1240 | } | ||
| 1241 | default: | ||
| 1242 | // Routing table decides (single source of truth, asserted | ||
| 1243 | // against captured wire traces): enumerated chatter is dropped, | ||
| 1244 | // everything else — including methods this build has never seen | ||
| 1245 | // — falls through to the parent path rather than vanishing. | ||
| 1246 | return routeCodexChildNotification(notification.method) === "drop"; | ||
| 1247 | } | ||
| 1248 | }); | ||
| 1249 | |||
| 842 | const handleRawNotification = (notification: CodexServerNotification) => | 1250 | const handleRawNotification = (notification: CodexServerNotification) => |
| 843 | Effect.gen(function* () { | 1251 | Effect.gen(function* () { |
| 844 | const payload = notification.params; | 1252 | const payload = notification.params; |
| ... | @@ -852,7 +1260,68 @@ export const makeCodexSessionRuntime = ( | ... | @@ -852,7 +1260,68 @@ export const makeCodexSessionRuntime = ( |
| 852 | })(); | 1260 | })(); |
| 853 | 1261 | ||
| 854 | rememberCollabReceiverTurns(collabReceiverTurns, notification, route.turnId); | 1262 | rememberCollabReceiverTurns(collabReceiverTurns, notification, route.turnId); |
| 855 | if (childParentTurnId && shouldSuppressChildConversationNotification(notification.method)) { | 1263 | // Interception FIRST: a registered v2 child is usually also in the |
| 1264 | // receiver-turn map (collabAgentToolCall.receiverThreadIds), and the | ||
| 1265 | // legacy suppressor below would drop its lifecycle before it could | ||
| 1266 | // become synthetic collabAgent events (review finding). The | ||
| 1267 | // suppressor still covers UNREGISTERED children. | ||
| 1268 | if (yield* interceptCollabChildNotification(notification)) { | ||
| 1269 | yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); | ||
| 1270 | return; | ||
| 1271 | } | ||
| 1272 | |||
| 1273 | // Suppression applies to receiver-map children (v1) AND to any | ||
| 1274 | // conversation that is not the root thread. The live capture | ||
| 1275 | // (codexMultiAgentWire.json) shows a child's thread/status/changed | ||
| 1276 | // arriving BEFORE anything registers the child — pre-registration | ||
| 1277 | // lifecycle must not reach the parent path, where the adapter maps | ||
| 1278 | // thread/* onto parent session state. Root-id-known guard keeps the | ||
| 1279 | // root's own early notifications flowing during session open. | ||
| 1280 | const suppressRootId = currentProviderThreadId(yield* Ref.get(sessionRef)); | ||
| 1281 | const foreignConversation = (() => { | ||
| 1282 | const providerConversationId = readNotificationThreadId(notification); | ||
| 1283 | return ( | ||
| 1284 | providerConversationId !== undefined && | ||
| 1285 | suppressRootId !== undefined && | ||
| 1286 | providerConversationId !== suppressRootId | ||
| 1287 | ); | ||
| 1288 | })(); | ||
| 1289 | if ( | ||
| 1290 | (childParentTurnId !== undefined || foreignConversation) && | ||
| 1291 | shouldSuppressChildConversationNotification(notification.method) | ||
| 1292 | ) { | ||
| 1293 | // Stop-everything must not depend on registration timing: a | ||
| 1294 | // child's turn/started can arrive before the subAgentActivity that | ||
| 1295 | // registers it (captured ordering), and suppressing it without | ||
| 1296 | // remembering the live turn would leave that child running after | ||
| 1297 | // Stop (review finding). Track live turns for ANY foreign | ||
| 1298 | // conversation; interrupts are best-effort per child, so a | ||
| 1299 | // false-positive entry costs one ignored RPC at worst. | ||
| 1300 | const foreignThreadId = readNotificationThreadId(notification); | ||
| 1301 | if (foreignThreadId !== undefined) { | ||
| 1302 | if (notification.method === "turn/started") { | ||
| 1303 | const foreignTurnId = | ||
| 1304 | typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" | ||
| 1305 | ? (notification.params as { turn: { id: string } }).turn.id | ||
| 1306 | : undefined; | ||
| 1307 | if (foreignTurnId) { | ||
| 1308 | yield* Ref.update(collabChildLiveTurnsRef, (current) => { | ||
| 1309 | const next = new Map(current); | ||
| 1310 | next.set(foreignThreadId, foreignTurnId); | ||
| 1311 | return next; | ||
| 1312 | }); | ||
| 1313 | } | ||
| 1314 | } else if ( | ||
| 1315 | notification.method === "turn/completed" || | ||
| 1316 | notification.method === "thread/closed" | ||
| 1317 | ) { | ||
| 1318 | yield* Ref.update(collabChildLiveTurnsRef, (current) => { | ||
| 1319 | const next = new Map(current); | ||
| 1320 | next.delete(foreignThreadId); | ||
| 1321 | return next; | ||
| 1322 | }); | ||
| 1323 | } | ||
| 1324 | } | ||
| 856 | yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); | 1325 | yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); |
| 857 | return; | 1326 | return; |
| 858 | } | 1327 | } |
| ... | @@ -1331,6 +1800,26 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1331,6 +1800,26 @@ export const makeCodexSessionRuntime = ( |
| 1331 | Effect.gen(function* () { | 1800 | Effect.gen(function* () { |
| 1332 | const providerThreadId = yield* readProviderThreadId; | 1801 | const providerThreadId = yield* readProviderThreadId; |
| 1333 | const session = yield* Ref.get(sessionRef); | 1802 | const session = yield* Ref.get(sessionRef); |
| 1803 | // Stop-everything: children are full threads with their own turns; | ||
| 1804 | // interrupting only the parent leaves the fleet running. Interrupt | ||
| 1805 | // each live child turn first, best-effort per child, BOUNDED: the | ||
| 1806 | // transport awaits an unbounded Deferred per request, so a wedged | ||
| 1807 | // child would otherwise block the parent interrupt forever — | ||
| 1808 | // exactly during the runaway fleet where Stop matters most | ||
| 1809 | // (review finding). Per-child and overall deadlines guarantee the | ||
| 1810 | // parent interrupt below always runs. | ||
| 1811 | const liveChildTurns = yield* Ref.get(collabChildLiveTurnsRef); | ||
| 1812 | yield* Effect.forEach( | ||
| 1813 | Array.from(liveChildTurns.entries()), | ||
| 1814 | ([childThreadId, childTurnId]) => | ||
| 1815 | client | ||
| 1816 | .request("turn/interrupt", { | ||
| 1817 | threadId: childThreadId, | ||
| 1818 | turnId: childTurnId, | ||
| 1819 | }) | ||
| 1820 | .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), | ||
| 1821 | { concurrency: 8, discard: true }, | ||
| 1822 | ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); | ||
| 1334 | const effectiveTurnId = turnId ?? session.activeTurnId; | 1823 | const effectiveTurnId = turnId ?? session.activeTurnId; |
| 1335 | if (!effectiveTurnId) { | 1824 | if (!effectiveTurnId) { |
| 1336 | return; | 1825 | return; |
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | // Minimal codex app-server stand-in for runtime-level collab tests. | ||
| 2 | // Speaks just enough of the protocol for CodexSessionRuntime to start a | ||
| 3 | // session, using REAL captured responses (codexMultiAgentWire.json), then | ||
| 4 | // replays a scripted multi-agent notification sequence read from the | ||
| 5 | // T3_CODEX_COLLAB_SCRIPT env var (a JSON file path) when the first turn | ||
| 6 | // starts. Runs as a plain Node process — stdlib only. | ||
| 7 | import * as NodeFS from "node:fs"; | ||
| 8 | import * as NodeReadline from "node:readline"; | ||
| 9 | import * as NodePath from "node:path"; | ||
| 10 | import * as NodeURL from "node:url"; | ||
| 11 | |||
| 12 | const here = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | ||
| 13 | const fixture = JSON.parse( | ||
| 14 | NodeFS.readFileSync(NodePath.join(here, "codexMultiAgentWire.json"), "utf8"), | ||
| 15 | ); | ||
| 16 | const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT, "utf8")); | ||
| 17 | |||
| 18 | const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); | ||
| 19 | |||
| 20 | const rl = NodeReadline.createInterface({ input: process.stdin }); | ||
| 21 | rl.on("line", (line) => { | ||
| 22 | let message; | ||
| 23 | try { | ||
| 24 | message = JSON.parse(line); | ||
| 25 | } catch { | ||
| 26 | return; | ||
| 27 | } | ||
| 28 | const { id, method } = message; | ||
| 29 | if (method === "initialize") { | ||
| 30 | write({ | ||
| 31 | id, | ||
| 32 | result: { | ||
| 33 | userAgent: "t3-collab-mock/0.0.0", | ||
| 34 | codexHome: "/tmp", | ||
| 35 | platformFamily: "unix", | ||
| 36 | platformOs: "linux", | ||
| 37 | }, | ||
| 38 | }); | ||
| 39 | return; | ||
| 40 | } | ||
| 41 | if (method === "thread/start" || method === "thread/resume") { | ||
| 42 | write({ id, result: fixture.responses.threadStart }); | ||
| 43 | return; | ||
| 44 | } | ||
| 45 | if (method === "turn/start") { | ||
| 46 | write({ id, result: fixture.responses.turnStart }); | ||
| 47 | const rootThreadId = script.rootThreadId; | ||
| 48 | const turn = fixture.responses.turnStart.turn; | ||
| 49 | write({ | ||
| 50 | jsonrpc: "2.0", | ||
| 51 | method: "turn/started", | ||
| 52 | params: { threadId: rootThreadId, turn }, | ||
| 53 | }); | ||
| 54 | for (const notification of script.notifications) { | ||
| 55 | write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); | ||
| 56 | } | ||
| 57 | if (script.holdTurnOpen !== true) { | ||
| 58 | write({ | ||
| 59 | jsonrpc: "2.0", | ||
| 60 | method: "turn/completed", | ||
| 61 | params: { | ||
| 62 | threadId: rootThreadId, | ||
| 63 | turn: { ...turn, status: "completed" }, | ||
| 64 | }, | ||
| 65 | }); | ||
| 66 | } | ||
| 67 | return; | ||
| 68 | } | ||
| 69 | if (method === "turn/interrupt") { | ||
| 70 | // Record which thread/turn was interrupted (append-only sidecar file the | ||
| 71 | // test reads) so Stop coverage can assert every live child was reached. | ||
| 72 | // failInterruptFor simulates a dead child whose interrupt errors. | ||
| 73 | const target = message.params?.threadId; | ||
| 74 | NodeFS.appendFileSync( | ||
| 75 | `${process.env.T3_CODEX_COLLAB_SCRIPT}.interrupts`, | ||
| 76 | `${JSON.stringify({ threadId: target, turnId: message.params?.turnId })}\n`, | ||
| 77 | ); | ||
| 78 | if (script.failInterruptFor && script.failInterruptFor === target) { | ||
| 79 | write({ id, error: { code: -32000, message: "thread already closed" } }); | ||
| 80 | return; | ||
| 81 | } | ||
| 82 | if (script.hangInterruptFor && script.hangInterruptFor === target) { | ||
| 83 | // Never respond: simulates a wedged child whose RPC neither resolves | ||
| 84 | // nor rejects. The runtime's bounded deadline must move on. | ||
| 85 | return; | ||
| 86 | } | ||
| 87 | write({ id, result: {} }); | ||
| 88 | return; | ||
| 89 | } | ||
| 90 | if (id !== undefined) { | ||
| 91 | write({ id, result: {} }); | ||
| 92 | } | ||
| 93 | }); | ||
apps/server/src/provider/testFixtures/codexCollabMockPeer.sh created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Wrapper so CodexSessionRuntime can spawn the mock peer: the runtime always | ||
| 3 | # passes "app-server" as the first argument (real codex CLI subcommand); | ||
| 4 | # discard it and exec node on the .mjs peer. | ||
| 5 | shift | ||
| 6 | exec node "$(dirname "$0")/codexCollabMockPeer.mjs" "$@" | ||
apps/server/src/provider/testFixtures/codexMultiAgentWire.json created+445| ... | @@ -0,0 +1,445 @@ | ||
| 1 | { | ||
| 2 | "capturedWith": { | ||
| 3 | "cli": "codex-cli 0.145.0", | ||
| 4 | "model": "gpt-5.6-luna", | ||
| 5 | "effort": "low" | ||
| 6 | }, | ||
| 7 | "rootThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 8 | "childThreadIds": [ | ||
| 9 | "019fcfd6-2883-77e0-9013-4410ede70371", | ||
| 10 | "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf" | ||
| 11 | ], | ||
| 12 | "notifications": [ | ||
| 13 | { | ||
| 14 | "method": "thread/started", | ||
| 15 | "params": { | ||
| 16 | "thread": { | ||
| 17 | "id": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 18 | "extra": null, | ||
| 19 | "sessionId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 20 | "forkedFromId": null, | ||
| 21 | "parentThreadId": null, | ||
| 22 | "preview": "", | ||
| 23 | "ephemeral": false, | ||
| 24 | "historyMode": "legacy", | ||
| 25 | "modelProvider": "openai", | ||
| 26 | "createdAt": 1785898342, | ||
| 27 | "updatedAt": 1785898342, | ||
| 28 | "recencyAt": 1785898342, | ||
| 29 | "status": { | ||
| 30 | "type": "idle" | ||
| 31 | }, | ||
| 32 | "path": "/fixtures/codex/rollout.jsonl", | ||
| 33 | "cwd": "/workspace/repo", | ||
| 34 | "cliVersion": "0.145.0", | ||
| 35 | "source": "vscode", | ||
| 36 | "canAcceptDirectInput": true, | ||
| 37 | "threadSource": null, | ||
| 38 | "agentNickname": null, | ||
| 39 | "agentRole": null, | ||
| 40 | "gitInfo": null, | ||
| 41 | "name": null, | ||
| 42 | "turns": [] | ||
| 43 | } | ||
| 44 | } | ||
| 45 | }, | ||
| 46 | { | ||
| 47 | "method": "thread/status/changed", | ||
| 48 | "params": { | ||
| 49 | "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 50 | "status": { | ||
| 51 | "type": "active", | ||
| 52 | "activeFlags": [] | ||
| 53 | } | ||
| 54 | } | ||
| 55 | }, | ||
| 56 | { | ||
| 57 | "method": "turn/started", | ||
| 58 | "params": { | ||
| 59 | "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 60 | "turn": { | ||
| 61 | "id": "019fcfd6-1806-7de1-8564-de69fd55bffb", | ||
| 62 | "items": [], | ||
| 63 | "itemsView": "notLoaded", | ||
| 64 | "status": "inProgress", | ||
| 65 | "error": null, | ||
| 66 | "startedAt": 1785898342, | ||
| 67 | "completedAt": null, | ||
| 68 | "durationMs": null | ||
| 69 | } | ||
| 70 | } | ||
| 71 | }, | ||
| 72 | { | ||
| 73 | "method": "thread/status/changed", | ||
| 74 | "params": { | ||
| 75 | "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", | ||
| 76 | "status": { | ||
| 77 | "type": "idle" | ||
| 78 | } | ||
| 79 | } | ||
| 80 | }, | ||
| 81 | { | ||
| 82 | "method": "item/completed", | ||
| 83 | "params": { | ||
| 84 | "item": { | ||
| 85 | "type": "subAgentActivity", | ||
| 86 | "id": "call_S2JPiq0sMnJYS7kswVdvzEjB", | ||
| 87 | "kind": "started", | ||
| 88 | "agentThreadId": "019fcfd6-2883-77e0-9013-4410ede70371", | ||
| 89 | "agentPath": "/root/alpha" | ||
| 90 | }, | ||
| 91 | "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 92 | "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", | ||
| 93 | "completedAtMs": 1785898346687 | ||
| 94 | } | ||
| 95 | }, | ||
| 96 | { | ||
| 97 | "method": "thread/tokenUsage/updated", | ||
| 98 | "params": { | ||
| 99 | "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 100 | "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", | ||
| 101 | "tokenUsage": { | ||
| 102 | "total": { | ||
| 103 | "totalTokens": 18261, | ||
| 104 | "inputTokens": 18228, | ||
| 105 | "cachedInputTokens": 11008, | ||
| 106 | "cacheWriteInputTokens": 0, | ||
| 107 | "outputTokens": 33, | ||
| 108 | "reasoningOutputTokens": 0 | ||
| 109 | }, | ||
| 110 | "last": { | ||
| 111 | "totalTokens": 18261, | ||
| 112 | "inputTokens": 18228, | ||
| 113 | "cachedInputTokens": 11008, | ||
| 114 | "cacheWriteInputTokens": 0, | ||
| 115 | "outputTokens": 33, | ||
| 116 | "reasoningOutputTokens": 0 | ||
| 117 | }, | ||
| 118 | "modelContextWindow": 258400 | ||
| 119 | } | ||
| 120 | } | ||
| 121 | }, | ||
| 122 | { | ||
| 123 | "method": "thread/status/changed", | ||
| 124 | "params": { | ||
| 125 | "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", | ||
| 126 | "status": { | ||
| 127 | "type": "active", | ||
| 128 | "activeFlags": [] | ||
| 129 | } | ||
| 130 | } | ||
| 131 | }, | ||
| 132 | { | ||
| 133 | "method": "turn/started", | ||
| 134 | "params": { | ||
| 135 | "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", | ||
| 136 | "turn": { | ||
| 137 | "id": "019fcfd6-28bf-7e00-a873-4554526dc845", | ||
| 138 | "items": [], | ||
| 139 | "itemsView": "notLoaded", | ||
| 140 | "status": "inProgress", | ||
| 141 | "error": null, | ||
| 142 | "startedAt": 1785898346, | ||
| 143 | "completedAt": null, | ||
| 144 | "durationMs": null | ||
| 145 | } | ||
| 146 | } | ||
| 147 | }, | ||
| 148 | { | ||
| 149 | "method": "thread/status/changed", | ||
| 150 | "params": { | ||
| 151 | "threadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf", | ||
| 152 | "status": { | ||
| 153 | "type": "idle" | ||
| 154 | } | ||
| 155 | } | ||
| 156 | }, | ||
| 157 | { | ||
| 158 | "method": "item/completed", | ||
| 159 | "params": { | ||
| 160 | "item": { | ||
| 161 | "type": "subAgentActivity", | ||
| 162 | "id": "call_A2U7sfjmdGlUn5bn6gUNI0rl", | ||
| 163 | "kind": "started", | ||
| 164 | "agentThreadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf", | ||
| 165 | "agentPath": "/root/beta" | ||
| 166 | }, | ||
| 167 | "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 168 | "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", | ||
| 169 | "completedAtMs": 1785898348329 | ||
| 170 | } | ||
| 171 | }, | ||
| 172 | { | ||
| 173 | "method": "thread/status/changed", | ||
| 174 | "params": { | ||
| 175 | "threadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf", | ||
| 176 | "status": { | ||
| 177 | "type": "active", | ||
| 178 | "activeFlags": [] | ||
| 179 | } | ||
| 180 | } | ||
| 181 | }, | ||
| 182 | { | ||
| 183 | "method": "turn/started", | ||
| 184 | "params": { | ||
| 185 | "threadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf", | ||
| 186 | "turn": { | ||
| 187 | "id": "019fcfd6-2f29-79e3-aa6a-c5836a519d3f", | ||
| 188 | "items": [], | ||
| 189 | "itemsView": "notLoaded", | ||
| 190 | "status": "inProgress", | ||
| 191 | "error": null, | ||
| 192 | "startedAt": 1785898348, | ||
| 193 | "completedAt": null, | ||
| 194 | "durationMs": null | ||
| 195 | } | ||
| 196 | } | ||
| 197 | }, | ||
| 198 | { | ||
| 199 | "method": "thread/tokenUsage/updated", | ||
| 200 | "params": { | ||
| 201 | "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 202 | "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", | ||
| 203 | "tokenUsage": { | ||
| 204 | "total": { | ||
| 205 | "totalTokens": 36576, | ||
| 206 | "inputTokens": 36510, | ||
| 207 | "cachedInputTokens": 28160, | ||
| 208 | "cacheWriteInputTokens": 0, | ||
| 209 | "outputTokens": 66, | ||
| 210 | "reasoningOutputTokens": 0 | ||
| 211 | }, | ||
| 212 | "last": { | ||
| 213 | "totalTokens": 18315, | ||
| 214 | "inputTokens": 18282, | ||
| 215 | "cachedInputTokens": 17152, | ||
| 216 | "cacheWriteInputTokens": 0, | ||
| 217 | "outputTokens": 33, | ||
| 218 | "reasoningOutputTokens": 0 | ||
| 219 | }, | ||
| 220 | "modelContextWindow": 258400 | ||
| 221 | } | ||
| 222 | } | ||
| 223 | }, | ||
| 224 | { | ||
| 225 | "method": "item/completed", | ||
| 226 | "params": { | ||
| 227 | "item": { | ||
| 228 | "type": "subAgentActivity", | ||
| 229 | "id": "call_2W1NboHClVo0VqAf1y3ZFinO", | ||
| 230 | "kind": "interacted", | ||
| 231 | "agentThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 232 | "agentPath": "/root" | ||
| 233 | }, | ||
| 234 | "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", | ||
| 235 | "turnId": "019fcfd6-28bf-7e00-a873-4554526dc845", | ||
| 236 | "completedAtMs": 1785898349265 | ||
| 237 | } | ||
| 238 | }, | ||
| 239 | { | ||
| 240 | "method": "thread/tokenUsage/updated", | ||
| 241 | "params": { | ||
| 242 | "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", | ||
| 243 | "turnId": "019fcfd6-28bf-7e00-a873-4554526dc845", | ||
| 244 | "tokenUsage": { | ||
| 245 | "total": { | ||
| 246 | "totalTokens": 20756, | ||
| 247 | "inputTokens": 20713, | ||
| 248 | "cachedInputTokens": 5888, | ||
| 249 | "cacheWriteInputTokens": 0, | ||
| 250 | "outputTokens": 43, | ||
| 251 | "reasoningOutputTokens": 11 | ||
| 252 | }, | ||
| 253 | "last": { | ||
| 254 | "totalTokens": 20756, | ||
| 255 | "inputTokens": 20713, | ||
| 256 | "cachedInputTokens": 5888, | ||
| 257 | "cacheWriteInputTokens": 0, | ||
| 258 | "outputTokens": 43, | ||
| 259 | "reasoningOutputTokens": 11 | ||
| 260 | }, | ||
| 261 | "modelContextWindow": 258400 | ||
| 262 | } | ||
| 263 | } | ||
| 264 | }, | ||
| 265 | { | ||
| 266 | "method": "item/started", | ||
| 267 | "params": { | ||
| 268 | "item": { | ||
| 269 | "type": "collabAgentToolCall", | ||
| 270 | "id": "call_XGMDYMm4O8ne7vg3Y6p4eSqz", | ||
| 271 | "tool": "wait", | ||
| 272 | "status": "inProgress", | ||
| 273 | "senderThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 274 | "receiverThreadIds": [], | ||
| 275 | "prompt": null, | ||
| 276 | "model": null, | ||
| 277 | "reasoningEffort": null, | ||
| 278 | "agentsStates": {} | ||
| 279 | }, | ||
| 280 | "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 281 | "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", | ||
| 282 | "startedAtMs": 1785898349931 | ||
| 283 | } | ||
| 284 | }, | ||
| 285 | { | ||
| 286 | "method": "item/completed", | ||
| 287 | "params": { | ||
| 288 | "item": { | ||
| 289 | "type": "collabAgentToolCall", | ||
| 290 | "id": "call_XGMDYMm4O8ne7vg3Y6p4eSqz", | ||
| 291 | "tool": "wait", | ||
| 292 | "status": "completed", | ||
| 293 | "senderThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 294 | "receiverThreadIds": [], | ||
| 295 | "prompt": null, | ||
| 296 | "model": null, | ||
| 297 | "reasoningEffort": null, | ||
| 298 | "agentsStates": {} | ||
| 299 | }, | ||
| 300 | "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 301 | "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", | ||
| 302 | "completedAtMs": 1785898349931 | ||
| 303 | } | ||
| 304 | }, | ||
| 305 | { | ||
| 306 | "method": "thread/tokenUsage/updated", | ||
| 307 | "params": { | ||
| 308 | "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 309 | "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", | ||
| 310 | "tokenUsage": { | ||
| 311 | "total": { | ||
| 312 | "totalTokens": 54933, | ||
| 313 | "inputTokens": 54846, | ||
| 314 | "cachedInputTokens": 45312, | ||
| 315 | "cacheWriteInputTokens": 0, | ||
| 316 | "outputTokens": 87, | ||
| 317 | "reasoningOutputTokens": 0 | ||
| 318 | }, | ||
| 319 | "last": { | ||
| 320 | "totalTokens": 18357, | ||
| 321 | "inputTokens": 18336, | ||
| 322 | "cachedInputTokens": 17152, | ||
| 323 | "cacheWriteInputTokens": 0, | ||
| 324 | "outputTokens": 21, | ||
| 325 | "reasoningOutputTokens": 0 | ||
| 326 | }, | ||
| 327 | "modelContextWindow": 258400 | ||
| 328 | } | ||
| 329 | } | ||
| 330 | }, | ||
| 331 | { | ||
| 332 | "method": "thread/tokenUsage/updated", | ||
| 333 | "params": { | ||
| 334 | "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", | ||
| 335 | "turnId": "019fcfd6-28bf-7e00-a873-4554526dc845", | ||
| 336 | "tokenUsage": { | ||
| 337 | "total": { | ||
| 338 | "totalTokens": 41529, | ||
| 339 | "inputTokens": 41481, | ||
| 340 | "cachedInputTokens": 26112, | ||
| 341 | "cacheWriteInputTokens": 0, | ||
| 342 | "outputTokens": 48, | ||
| 343 | "reasoningOutputTokens": 11 | ||
| 344 | }, | ||
| 345 | "last": { | ||
| 346 | "totalTokens": 20773, | ||
| 347 | "inputTokens": 20768, | ||
| 348 | "cachedInputTokens": 20224, | ||
| 349 | "cacheWriteInputTokens": 0, | ||
| 350 | "outputTokens": 5, | ||
| 351 | "reasoningOutputTokens": 0 | ||
| 352 | }, | ||
| 353 | "modelContextWindow": 258400 | ||
| 354 | } | ||
| 355 | } | ||
| 356 | }, | ||
| 357 | { | ||
| 358 | "method": "thread/status/changed", | ||
| 359 | "params": { | ||
| 360 | "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", | ||
| 361 | "status": { | ||
| 362 | "type": "idle" | ||
| 363 | } | ||
| 364 | } | ||
| 365 | }, | ||
| 366 | { | ||
| 367 | "method": "turn/completed", | ||
| 368 | "params": { | ||
| 369 | "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", | ||
| 370 | "turn": { | ||
| 371 | "id": "019fcfd6-28bf-7e00-a873-4554526dc845", | ||
| 372 | "items": [], | ||
| 373 | "itemsView": "notLoaded", | ||
| 374 | "status": "completed", | ||
| 375 | "error": null, | ||
| 376 | "startedAt": 1785898346, | ||
| 377 | "completedAt": 1785898350, | ||
| 378 | "durationMs": 3792 | ||
| 379 | } | ||
| 380 | } | ||
| 381 | } | ||
| 382 | ], | ||
| 383 | "responses": { | ||
| 384 | "threadStart": { | ||
| 385 | "thread": { | ||
| 386 | "id": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 387 | "extra": null, | ||
| 388 | "sessionId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", | ||
| 389 | "forkedFromId": null, | ||
| 390 | "parentThreadId": null, | ||
| 391 | "preview": "", | ||
| 392 | "ephemeral": false, | ||
| 393 | "historyMode": "legacy", | ||
| 394 | "modelProvider": "openai", | ||
| 395 | "createdAt": 1785898342, | ||
| 396 | "updatedAt": 1785898342, | ||
| 397 | "recencyAt": 1785898342, | ||
| 398 | "status": { | ||
| 399 | "type": "idle" | ||
| 400 | }, | ||
| 401 | "path": "/fixtures/codex/rollout.jsonl", | ||
| 402 | "cwd": "/workspace/repo", | ||
| 403 | "cliVersion": "0.145.0", | ||
| 404 | "source": "vscode", | ||
| 405 | "canAcceptDirectInput": true, | ||
| 406 | "threadSource": null, | ||
| 407 | "agentNickname": null, | ||
| 408 | "agentRole": null, | ||
| 409 | "gitInfo": null, | ||
| 410 | "name": null, | ||
| 411 | "turns": [] | ||
| 412 | }, | ||
| 413 | "model": "gpt-5.6-sol", | ||
| 414 | "modelProvider": "openai", | ||
| 415 | "serviceTier": "default", | ||
| 416 | "cwd": "/workspace/repo", | ||
| 417 | "runtimeWorkspaceRoots": ["/workspace/repo"], | ||
| 418 | "instructionSources": ["/fixtures/scrubbed", "/workspace/repo/AGENTS.md"], | ||
| 419 | "approvalPolicy": "on-request", | ||
| 420 | "approvalsReviewer": "auto_review", | ||
| 421 | "sandbox": { | ||
| 422 | "type": "workspaceWrite", | ||
| 423 | "writableRoots": [], | ||
| 424 | "networkAccess": false, | ||
| 425 | "excludeTmpdirEnvVar": false, | ||
| 426 | "excludeSlashTmp": false | ||
| 427 | }, | ||
| 428 | "activePermissionProfile": null, | ||
| 429 | "reasoningEffort": "high", | ||
| 430 | "multiAgentMode": "explicitRequestOnly" | ||
| 431 | }, | ||
| 432 | "turnStart": { | ||
| 433 | "turn": { | ||
| 434 | "id": "019fcfd6-1806-7de1-8564-de69fd55bffb", | ||
| 435 | "items": [], | ||
| 436 | "itemsView": "notLoaded", | ||
| 437 | "status": "inProgress", | ||
| 438 | "error": null, | ||
| 439 | "startedAt": null, | ||
| 440 | "completedAt": null, | ||
| 441 | "durationMs": null | ||
| 442 | } | ||
| 443 | } | ||
| 444 | } | ||
| 445 | } | ||
apps/server/src/ws.ts+7| ... | @@ -91,6 +91,7 @@ import { issueAssetUrl } from "./assets/AssetAccess.ts"; | ... | @@ -91,6 +91,7 @@ import { issueAssetUrl } from "./assets/AssetAccess.ts"; |
| 91 | import * as PortScanner from "./preview/PortScanner.ts"; | 91 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 92 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 92 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 93 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; | 93 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 94 | import { readWorkflowScript } from "./orchestration/workflowScriptQuery.ts"; | ||
| 94 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; | 95 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| 95 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; | 96 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 96 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; | 97 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| ... | @@ -1084,6 +1085,12 @@ const makeWsRpcLayer = ( | ... | @@ -1084,6 +1085,12 @@ const makeWsRpcLayer = ( |
| 1084 | ), | 1085 | ), |
| 1085 | { "rpc.aggregate": "orchestration" }, | 1086 | { "rpc.aggregate": "orchestration" }, |
| 1086 | ), | 1087 | ), |
| 1088 | [ORCHESTRATION_WS_METHODS.getWorkflowScript]: (input) => | ||
| 1089 | observeRpcEffect( | ||
| 1090 | ORCHESTRATION_WS_METHODS.getWorkflowScript, | ||
| 1091 | readWorkflowScript({ scriptPath: input.scriptPath }), | ||
| 1092 | { "rpc.aggregate": "orchestration" }, | ||
| 1093 | ), | ||
| 1087 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => | 1094 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => |
| 1088 | observeRpcEffect( | 1095 | observeRpcEffect( |
| 1089 | ORCHESTRATION_WS_METHODS.getTurnDiff, | 1096 | ORCHESTRATION_WS_METHODS.getTurnDiff, |
apps/web/src/components/AgentsPanel.tsx created+568| ... | @@ -0,0 +1,568 @@ | ||
| 1 | /** | ||
| 2 | * Agents right-panel surface: the fleet view over the native subagent fold, | ||
| 3 | * and the ONLY place the roster renders (the chat carries one CTA row per | ||
| 4 | * spawn batch). | ||
| 5 | * | ||
| 6 | * Visualization rules (from live-test feedback): | ||
| 7 | * - Live work first: running workflows and direct spawns sort above settled. | ||
| 8 | * - Rows are flat status lines — no expansion, no per-agent tool feeds. The | ||
| 9 | * row answers "who / what phase / how much"; anything deeper is a future | ||
| 10 | * drill-in, not an unfold. | ||
| 11 | * - A settled workflow run collapses to a single summary line; click it to | ||
| 12 | * show its member list inline (the one allowed toggle — run granularity, | ||
| 13 | * not agent granularity). | ||
| 14 | * - Static status dots, DOM-write elapsed timers, plain token counters. | ||
| 15 | */ | ||
| 16 | import { useAtomValue } from "@effect/atom-react"; | ||
| 17 | import type { | ||
| 18 | AgentPanelModel, | ||
| 19 | AgentPanelWorkflowGroup, | ||
| 20 | RuntimeSubagent, | ||
| 21 | } from "@t3tools/client-runtime/state/subagentRuntime"; | ||
| 22 | import { | ||
| 23 | formatSubagentModelLabel, | ||
| 24 | formatSubagentTokenCount, | ||
| 25 | } from "@t3tools/client-runtime/state/subagentRuntime"; | ||
| 26 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 27 | import { Bot, Braces, Check, ChevronDown, ChevronRight, X } from "lucide-react"; | ||
| 28 | import { useEffect, useRef, useState } from "react"; | ||
| 29 | |||
| 30 | import { cn } from "~/lib/utils"; | ||
| 31 | import { orchestrationEnvironment } from "~/state/orchestration"; | ||
| 32 | import { ScrollArea } from "~/components/ui/scroll-area"; | ||
| 33 | |||
| 34 | /** | ||
| 35 | * In-flight states all present as Working (one steady state, per the | ||
| 36 | * monitoring-pill design: detail belongs in the activity sub-line, and a | ||
| 37 | * stalled/waiting/queued subagent is still the fleet doing its job, not a | ||
| 38 | * user problem). Only settled states differentiate. | ||
| 39 | */ | ||
| 40 | const STATUS_VISUALS: Record<RuntimeSubagent["status"], { dotClass: string; label: string }> = { | ||
| 41 | pending: { dotClass: "bg-info", label: "Working" }, | ||
| 42 | running: { dotClass: "bg-info", label: "Working" }, | ||
| 43 | waiting: { dotClass: "bg-info", label: "Working" }, | ||
| 44 | // Idle reads as settled (muted, not sky): a resting Codex child looks done | ||
| 45 | // unless resumed — live-test: sky idle dots read as stuck in-progress. | ||
| 46 | idle: { dotClass: "bg-muted-foreground/50", label: "Idle · resumable" }, | ||
| 47 | completed: { dotClass: "bg-success", label: "Completed" }, | ||
| 48 | failed: { dotClass: "bg-destructive", label: "Failed" }, | ||
| 49 | cancelled: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, | ||
| 50 | interrupted: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, | ||
| 51 | }; | ||
| 52 | |||
| 53 | function StatusDot({ status }: { status: RuntimeSubagent["status"] }) { | ||
| 54 | return ( | ||
| 55 | <span | ||
| 56 | aria-hidden | ||
| 57 | className={cn("size-1.5 shrink-0 rounded-full", STATUS_VISUALS[status].dotClass)} | ||
| 58 | /> | ||
| 59 | ); | ||
| 60 | } | ||
| 61 | |||
| 62 | function formatElapsedSeconds(totalSeconds: number): string { | ||
| 63 | const seconds = Math.max(0, Math.floor(totalSeconds)); | ||
| 64 | const minutes = Math.floor(seconds / 60); | ||
| 65 | if (minutes === 0) { | ||
| 66 | return `${seconds}s`; | ||
| 67 | } | ||
| 68 | const hours = Math.floor(minutes / 60); | ||
| 69 | if (hours === 0) { | ||
| 70 | return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`; | ||
| 71 | } | ||
| 72 | return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; | ||
| 73 | } | ||
| 74 | |||
| 75 | function elapsedBetween(startedAt: string, endIso: string | null): string { | ||
| 76 | const start = Date.parse(startedAt); | ||
| 77 | const end = endIso ? Date.parse(endIso) : Date.now(); | ||
| 78 | if (Number.isNaN(start) || Number.isNaN(end)) { | ||
| 79 | return ""; | ||
| 80 | } | ||
| 81 | return formatElapsedSeconds((end - start) / 1000); | ||
| 82 | } | ||
| 83 | |||
| 84 | /** | ||
| 85 | * Elapsed time for the current activation. Live agents self-tick via DOM | ||
| 86 | * writes (zero React commits per tick); settled agents freeze at completedAt. | ||
| 87 | */ | ||
| 88 | function AgentElapsed({ agent }: { agent: RuntimeSubagent }) { | ||
| 89 | const textRef = useRef<HTMLSpanElement>(null); | ||
| 90 | const live = agent.status === "running" || agent.status === "waiting"; | ||
| 91 | const startedAt = agent.startedAt; | ||
| 92 | |||
| 93 | useEffect(() => { | ||
| 94 | if (!live || !startedAt) { | ||
| 95 | return; | ||
| 96 | } | ||
| 97 | const update = () => { | ||
| 98 | if (textRef.current) { | ||
| 99 | textRef.current.textContent = elapsedBetween(startedAt, null); | ||
| 100 | } | ||
| 101 | }; | ||
| 102 | update(); | ||
| 103 | const id = setInterval(update, 1000); | ||
| 104 | return () => clearInterval(id); | ||
| 105 | }, [live, startedAt]); | ||
| 106 | |||
| 107 | if (!startedAt) { | ||
| 108 | return null; | ||
| 109 | } | ||
| 110 | return ( | ||
| 111 | <span ref={textRef} className="tabular-nums"> | ||
| 112 | {elapsedBetween(startedAt, live ? null : agent.completedAt)} | ||
| 113 | </span> | ||
| 114 | ); | ||
| 115 | } | ||
| 116 | |||
| 117 | /** | ||
| 118 | * Status-dependent activity line. Live rows lead with what is happening now; | ||
| 119 | * settled rows lead with the outcome. Errors are the only inline previews on | ||
| 120 | * failed rows because they explain a red row at a glance. | ||
| 121 | */ | ||
| 122 | function agentActivityText(agent: RuntimeSubagent): string | null { | ||
| 123 | const live = | ||
| 124 | agent.status === "running" || agent.status === "pending" || agent.status === "waiting"; | ||
| 125 | if (live) { | ||
| 126 | return ( | ||
| 127 | agent.progress ?? | ||
| 128 | (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) ?? | ||
| 129 | agent.result ?? | ||
| 130 | agent.error | ||
| 131 | ); | ||
| 132 | } | ||
| 133 | return ( | ||
| 134 | agent.error ?? | ||
| 135 | agent.result ?? | ||
| 136 | agent.progress ?? | ||
| 137 | (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) | ||
| 138 | ); | ||
| 139 | } | ||
| 140 | |||
| 141 | /** Flat, non-interactive agent status line. No unfold. */ | ||
| 142 | function AgentRow({ agent }: { agent: RuntimeSubagent }) { | ||
| 143 | const visuals = STATUS_VISUALS[agent.status]; | ||
| 144 | const activity = agentActivityText(agent); | ||
| 145 | const modelLabel = formatSubagentModelLabel(agent.model, agent.effort); | ||
| 146 | |||
| 147 | return ( | ||
| 148 | <div className="rounded-md px-1.5 py-1"> | ||
| 149 | <div className="flex items-start gap-2"> | ||
| 150 | <span className="flex h-5 items-center"> | ||
| 151 | <StatusDot status={agent.status} /> | ||
| 152 | </span> | ||
| 153 | <span className="min-w-0 flex-1"> | ||
| 154 | <span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5"> | ||
| 155 | <span className="truncate text-sm font-medium">{agent.title}</span> | ||
| 156 | {agent.role ? ( | ||
| 157 | <span className="max-w-28 truncate rounded-sm border border-border/60 px-1 font-mono text-[.65rem] text-muted-foreground"> | ||
| 158 | {agent.role} | ||
| 159 | </span> | ||
| 160 | ) : null} | ||
| 161 | <span className="ml-auto flex items-center gap-1 font-mono text-[.7rem] text-muted-foreground/80"> | ||
| 162 | <AgentElapsed agent={agent} /> | ||
| 163 | {agent.status === "completed" ? ( | ||
| 164 | <Check aria-hidden className="size-3 text-success" /> | ||
| 165 | ) : null} | ||
| 166 | </span> | ||
| 167 | </span> | ||
| 168 | {activity ? ( | ||
| 169 | <span | ||
| 170 | className={cn( | ||
| 171 | "mt-0.5 block truncate text-xs", | ||
| 172 | agent.status === "failed" ? "text-destructive-foreground" : "text-muted-foreground", | ||
| 173 | )} | ||
| 174 | > | ||
| 175 | {activity} | ||
| 176 | </span> | ||
| 177 | ) : null} | ||
| 178 | <span className="mt-0.5 flex items-center gap-1 font-mono text-[.7rem] text-muted-foreground/70"> | ||
| 179 | {modelLabel ? <span className="truncate">{modelLabel}</span> : null} | ||
| 180 | {agent.usage ? ( | ||
| 181 | <span className="tabular-nums"> | ||
| 182 | {modelLabel ? "· " : ""} | ||
| 183 | {formatSubagentTokenCount(agent.usage.totalTokens)} tok | ||
| 184 | </span> | ||
| 185 | ) : null} | ||
| 186 | {agent.usage?.toolUses !== undefined ? ( | ||
| 187 | <span>· {agent.usage.toolUses} tools</span> | ||
| 188 | ) : null} | ||
| 189 | {agent.activationCount > 1 ? <span>· run {agent.activationCount}</span> : null} | ||
| 190 | <span className="sr-only">{visuals.label}</span> | ||
| 191 | </span> | ||
| 192 | </span> | ||
| 193 | </div> | ||
| 194 | </div> | ||
| 195 | ); | ||
| 196 | } | ||
| 197 | |||
| 198 | function workflowIsLive(group: AgentPanelWorkflowGroup): boolean { | ||
| 199 | const status = group.workflow.status; | ||
| 200 | return ( | ||
| 201 | status !== "completed" && | ||
| 202 | status !== "failed" && | ||
| 203 | status !== "cancelled" && | ||
| 204 | status !== "interrupted" | ||
| 205 | ); | ||
| 206 | } | ||
| 207 | |||
| 208 | function workflowMembers(group: AgentPanelWorkflowGroup): ReadonlyArray<RuntimeSubagent> { | ||
| 209 | return [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; | ||
| 210 | } | ||
| 211 | |||
| 212 | /** | ||
| 213 | * Phase rail: the run's shape at a glance. One segment per phase in order, | ||
| 214 | * separated by chevrons; each segment shows title + one dot per member. | ||
| 215 | * The whole arc (done → live → pending) is visible without scrolling the | ||
| 216 | * member list. | ||
| 217 | */ | ||
| 218 | function PhaseRail({ group }: { group: AgentPanelWorkflowGroup }) { | ||
| 219 | if (group.phases.length === 0) { | ||
| 220 | return null; | ||
| 221 | } | ||
| 222 | return ( | ||
| 223 | <div className="flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 pb-1 pt-1.5"> | ||
| 224 | {group.phases.map((phase, index) => ( | ||
| 225 | <div key={phase.index} className="flex items-center gap-1"> | ||
| 226 | {index > 0 ? ( | ||
| 227 | <ChevronRight aria-hidden className="size-3 text-muted-foreground/40" /> | ||
| 228 | ) : null} | ||
| 229 | <div | ||
| 230 | className={cn( | ||
| 231 | "flex items-center gap-1 rounded-sm border px-1.5 py-0.5", | ||
| 232 | phase.state === "running" | ||
| 233 | ? "border-info/40" | ||
| 234 | : phase.state === "done" | ||
| 235 | ? "border-success/30" | ||
| 236 | : "border-border/50", | ||
| 237 | )} | ||
| 238 | > | ||
| 239 | <span | ||
| 240 | className={cn( | ||
| 241 | "font-mono text-[.65rem]", | ||
| 242 | phase.state === "running" | ||
| 243 | ? "text-info-foreground" | ||
| 244 | : phase.state === "done" | ||
| 245 | ? "text-success-foreground" | ||
| 246 | : "text-muted-foreground/70", | ||
| 247 | )} | ||
| 248 | > | ||
| 249 | {phase.state === "done" ? "✓ " : ""} | ||
| 250 | {phase.title} | ||
| 251 | </span> | ||
| 252 | <span className="flex items-center gap-0.5"> | ||
| 253 | {phase.members.length === 0 ? ( | ||
| 254 | <span className="font-mono text-[.6rem] text-muted-foreground/50">–</span> | ||
| 255 | ) : ( | ||
| 256 | phase.members.map((member) => <StatusDot key={member.id} status={member.status} />) | ||
| 257 | )} | ||
| 258 | </span> | ||
| 259 | </div> | ||
| 260 | </div> | ||
| 261 | ))} | ||
| 262 | </div> | ||
| 263 | ); | ||
| 264 | } | ||
| 265 | |||
| 266 | /** | ||
| 267 | * Read-only workflow script viewer, fetched through the contained | ||
| 268 | * getWorkflowScript RPC (never a raw filesystem read from the client). | ||
| 269 | */ | ||
| 270 | function WorkflowScriptView({ | ||
| 271 | environmentId, | ||
| 272 | threadId, | ||
| 273 | scriptPath, | ||
| 274 | onClose, | ||
| 275 | }: { | ||
| 276 | environmentId: EnvironmentId; | ||
| 277 | threadId: ThreadId; | ||
| 278 | scriptPath: string; | ||
| 279 | onClose: () => void; | ||
| 280 | }) { | ||
| 281 | const result = useAtomValue( | ||
| 282 | orchestrationEnvironment.workflowScript({ environmentId, input: { threadId, scriptPath } }), | ||
| 283 | ); | ||
| 284 | return ( | ||
| 285 | <div className="mx-1.5 mb-1 rounded-md border border-border/60 bg-background/60"> | ||
| 286 | <div className="flex items-center gap-2 border-b border-border/50 px-2 py-1"> | ||
| 287 | <Braces aria-hidden className="size-3 text-muted-foreground" /> | ||
| 288 | <span className="truncate font-mono text-[.65rem] text-muted-foreground"> | ||
| 289 | {scriptPath.split("/").at(-1)} | ||
| 290 | </span> | ||
| 291 | <button | ||
| 292 | type="button" | ||
| 293 | onClick={onClose} | ||
| 294 | aria-label="Close script" | ||
| 295 | className="ml-auto text-muted-foreground hover:text-foreground" | ||
| 296 | > | ||
| 297 | <X aria-hidden className="size-3" /> | ||
| 298 | </button> | ||
| 299 | </div> | ||
| 300 | <div className="max-h-72 overflow-auto p-2"> | ||
| 301 | {result._tag === "Success" ? ( | ||
| 302 | <pre className="whitespace-pre-wrap break-words font-mono text-[.7rem] leading-relaxed text-foreground/90"> | ||
| 303 | {result.value.contents} | ||
| 304 | {result.value.truncated ? "\n… (truncated)" : ""} | ||
| 305 | </pre> | ||
| 306 | ) : result._tag === "Failure" ? ( | ||
| 307 | <p className="text-xs text-destructive-foreground">Could not load the script.</p> | ||
| 308 | ) : ( | ||
| 309 | <p className="text-xs text-muted-foreground">Loading…</p> | ||
| 310 | )} | ||
| 311 | </div> | ||
| 312 | </div> | ||
| 313 | ); | ||
| 314 | } | ||
| 315 | |||
| 316 | /** | ||
| 317 | * Collapsible phase section (Claude Code Background-tasks pattern): live | ||
| 318 | * phases open by default, done phases collapsed to header + member dot row. | ||
| 319 | * User toggles override the default and stick for the phase's lifetime. | ||
| 320 | */ | ||
| 321 | function PhaseSection({ phase }: { phase: AgentPanelWorkflowGroup["phases"][number] }) { | ||
| 322 | const [userOpen, setUserOpen] = useState<boolean | null>(null); | ||
| 323 | const open = userOpen ?? phase.state === "running"; | ||
| 324 | return ( | ||
| 325 | <div> | ||
| 326 | <button | ||
| 327 | type="button" | ||
| 328 | onClick={() => setUserOpen(!open)} | ||
| 329 | aria-expanded={open} | ||
| 330 | className={cn( | ||
| 331 | "mt-2 flex w-full items-center gap-1.5 rounded-sm px-1.5 text-left text-[.65rem] font-medium uppercase tracking-wider hover:bg-accent/40", | ||
| 332 | phase.state === "done" | ||
| 333 | ? "text-success-foreground" | ||
| 334 | : phase.state === "running" | ||
| 335 | ? "text-info-foreground" | ||
| 336 | : "text-muted-foreground/70", | ||
| 337 | )} | ||
| 338 | > | ||
| 339 | {open ? ( | ||
| 340 | <ChevronDown aria-hidden className="size-3 shrink-0" /> | ||
| 341 | ) : ( | ||
| 342 | <ChevronRight aria-hidden className="size-3 shrink-0" /> | ||
| 343 | )} | ||
| 344 | {phase.state === "done" ? <Check aria-hidden className="size-3" /> : null} | ||
| 345 | <span>{phase.title}</span> | ||
| 346 | <span className="font-normal normal-case text-muted-foreground/70"> | ||
| 347 | {phase.state === "pending" && phase.members.length === 0 | ||
| 348 | ? "pending" | ||
| 349 | : phase.state === "done" | ||
| 350 | ? `${phase.settledCount} done` | ||
| 351 | : `${phase.activeCount} active · ${phase.settledCount} done`} | ||
| 352 | </span> | ||
| 353 | {!open && phase.members.length > 0 ? ( | ||
| 354 | <span className="ml-auto flex items-center gap-0.5"> | ||
| 355 | {phase.members.map((member) => ( | ||
| 356 | <StatusDot key={member.id} status={member.status} /> | ||
| 357 | ))} | ||
| 358 | </span> | ||
| 359 | ) : null} | ||
| 360 | </button> | ||
| 361 | {open ? phase.members.map((member) => <AgentRow key={member.id} agent={member} />) : null} | ||
| 362 | </div> | ||
| 363 | ); | ||
| 364 | } | ||
| 365 | |||
| 366 | /** Live workflow: phase rail + full phase tree. */ | ||
| 367 | function LiveWorkflowSection({ | ||
| 368 | group, | ||
| 369 | environmentId, | ||
| 370 | threadId, | ||
| 371 | }: { | ||
| 372 | group: AgentPanelWorkflowGroup; | ||
| 373 | environmentId: EnvironmentId | null; | ||
| 374 | threadId: ThreadId | null; | ||
| 375 | }) { | ||
| 376 | const [scriptOpen, setScriptOpen] = useState(false); | ||
| 377 | const members = workflowMembers(group); | ||
| 378 | const settled = members.filter( | ||
| 379 | (member) => | ||
| 380 | member.status === "completed" || | ||
| 381 | member.status === "failed" || | ||
| 382 | member.status === "cancelled" || | ||
| 383 | member.status === "interrupted", | ||
| 384 | ).length; | ||
| 385 | const scriptPath = group.workflow.runHandles?.scriptPath; | ||
| 386 | const canShowScript = scriptPath !== undefined && environmentId !== null && threadId !== null; | ||
| 387 | return ( | ||
| 388 | <section className="rounded-lg border border-border/50 bg-card/30 p-1.5"> | ||
| 389 | <div className="flex items-center gap-2 px-1.5 pt-0.5 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground"> | ||
| 390 | <span aria-hidden className="size-1.5 rounded-full bg-info" /> | ||
| 391 | <span>{group.workflow.workflowName ?? group.workflow.title}</span> | ||
| 392 | {canShowScript ? ( | ||
| 393 | <button | ||
| 394 | type="button" | ||
| 395 | onClick={() => setScriptOpen((value) => !value)} | ||
| 396 | className={cn( | ||
| 397 | "rounded-sm border border-border/60 px-1 font-mono normal-case hover:text-foreground", | ||
| 398 | scriptOpen && "text-foreground", | ||
| 399 | )} | ||
| 400 | aria-expanded={scriptOpen} | ||
| 401 | > | ||
| 402 | {"{}"} script | ||
| 403 | </button> | ||
| 404 | ) : null} | ||
| 405 | <span className="ml-auto font-mono normal-case text-muted-foreground/80"> | ||
| 406 | {settled}/{members.length} settled | ||
| 407 | </span> | ||
| 408 | </div> | ||
| 409 | <PhaseRail group={group} /> | ||
| 410 | {scriptOpen && canShowScript ? ( | ||
| 411 | <WorkflowScriptView | ||
| 412 | environmentId={environmentId} | ||
| 413 | threadId={threadId} | ||
| 414 | scriptPath={scriptPath} | ||
| 415 | onClose={() => setScriptOpen(false)} | ||
| 416 | /> | ||
| 417 | ) : null} | ||
| 418 | {group.phases.map((phase) => ( | ||
| 419 | <PhaseSection key={phase.index} phase={phase} /> | ||
| 420 | ))} | ||
| 421 | {group.unphasedMembers.map((member) => ( | ||
| 422 | <AgentRow key={member.id} agent={member} /> | ||
| 423 | ))} | ||
| 424 | {group.phases.length === 0 && group.unphasedMembers.length === 0 ? ( | ||
| 425 | <AgentRow agent={group.workflow} /> | ||
| 426 | ) : null} | ||
| 427 | </section> | ||
| 428 | ); | ||
| 429 | } | ||
| 430 | |||
| 431 | /** | ||
| 432 | * Settled workflow: one summary line. Click toggles the member list — the | ||
| 433 | * only expansion in the panel, at run granularity. | ||
| 434 | */ | ||
| 435 | function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) { | ||
| 436 | const [open, setOpen] = useState(false); | ||
| 437 | const members = workflowMembers(group); | ||
| 438 | const failed = members.filter((member) => member.status === "failed").length; | ||
| 439 | // Coordinator usage may already aggregate members (panel-footer rule): | ||
| 440 | // count it only when there are no member rows to sum. | ||
| 441 | const totalTokens = members.reduce( | ||
| 442 | (sum, member) => sum + (member.usage?.totalTokens ?? 0), | ||
| 443 | members.length === 0 ? (group.workflow.usage?.totalTokens ?? 0) : 0, | ||
| 444 | ); | ||
| 445 | const elapsed = | ||
| 446 | group.workflow.startedAt && group.workflow.completedAt | ||
| 447 | ? elapsedBetween(group.workflow.startedAt, group.workflow.completedAt) | ||
| 448 | : null; | ||
| 449 | return ( | ||
| 450 | <section> | ||
| 451 | <button | ||
| 452 | type="button" | ||
| 453 | onClick={() => setOpen((value) => !value)} | ||
| 454 | className="flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left hover:bg-accent/40" | ||
| 455 | aria-expanded={open} | ||
| 456 | > | ||
| 457 | <StatusDot status={failed > 0 ? "failed" : group.workflow.status} /> | ||
| 458 | <span className="truncate text-sm"> | ||
| 459 | {group.workflow.workflowName ?? group.workflow.title} | ||
| 460 | </span> | ||
| 461 | <span className="ml-auto flex items-center gap-1.5 font-mono text-[.7rem] text-muted-foreground/80"> | ||
| 462 | {failed > 0 ? <span className="text-destructive-foreground">{failed} failed</span> : null} | ||
| 463 | <span>{members.length} agents</span> | ||
| 464 | <span className="tabular-nums">· {formatSubagentTokenCount(totalTokens)} tok</span> | ||
| 465 | {elapsed ? <span className="tabular-nums">· {elapsed}</span> : null} | ||
| 466 | {open ? ( | ||
| 467 | <ChevronDown aria-hidden className="size-3" /> | ||
| 468 | ) : ( | ||
| 469 | <ChevronRight aria-hidden className="size-3" /> | ||
| 470 | )} | ||
| 471 | </span> | ||
| 472 | </button> | ||
| 473 | {open ? ( | ||
| 474 | <div className="ms-3 border-s border-border/45 ps-2"> | ||
| 475 | {members.map((member) => ( | ||
| 476 | <AgentRow key={member.id} agent={member} /> | ||
| 477 | ))} | ||
| 478 | </div> | ||
| 479 | ) : null} | ||
| 480 | </section> | ||
| 481 | ); | ||
| 482 | } | ||
| 483 | |||
| 484 | export function AgentsPanel({ | ||
| 485 | model, | ||
| 486 | environmentId = null, | ||
| 487 | threadId = null, | ||
| 488 | }: { | ||
| 489 | model: AgentPanelModel; | ||
| 490 | environmentId?: EnvironmentId | null; | ||
| 491 | threadId?: ThreadId | null; | ||
| 492 | }) { | ||
| 493 | if (!model.hasAgents) { | ||
| 494 | return ( | ||
| 495 | <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center"> | ||
| 496 | <Bot aria-hidden className="size-6 text-muted-foreground/60" /> | ||
| 497 | <p className="text-sm font-medium">No agents yet</p> | ||
| 498 | <p className="max-w-56 text-xs text-muted-foreground"> | ||
| 499 | When this thread spawns subagents or runs a workflow, they show up here with live status, | ||
| 500 | activity, and token usage. | ||
| 501 | </p> | ||
| 502 | </div> | ||
| 503 | ); | ||
| 504 | } | ||
| 505 | |||
| 506 | const liveWorkflows = model.workflows.filter(workflowIsLive); | ||
| 507 | const settledWorkflows = model.workflows.filter((group) => !workflowIsLive(group)); | ||
| 508 | const liveDirect = model.directAgents.filter( | ||
| 509 | (agent) => | ||
| 510 | agent.status === "running" || agent.status === "pending" || agent.status === "waiting", | ||
| 511 | ); | ||
| 512 | const settledDirect = model.directAgents.filter( | ||
| 513 | (agent) => | ||
| 514 | agent.status !== "running" && agent.status !== "pending" && agent.status !== "waiting", | ||
| 515 | ); | ||
| 516 | |||
| 517 | return ( | ||
| 518 | <div className="flex h-full min-h-0 flex-col"> | ||
| 519 | <ScrollArea className="min-h-0 flex-1"> | ||
| 520 | <div className="flex flex-col gap-2 p-2"> | ||
| 521 | {liveWorkflows.map((group) => ( | ||
| 522 | <LiveWorkflowSection | ||
| 523 | key={group.workflow.id} | ||
| 524 | group={group} | ||
| 525 | environmentId={environmentId} | ||
| 526 | threadId={threadId} | ||
| 527 | /> | ||
| 528 | ))} | ||
| 529 | {liveDirect.length > 0 ? ( | ||
| 530 | <section> | ||
| 531 | <div className="px-1.5 pt-1 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground"> | ||
| 532 | Direct spawns | ||
| 533 | </div> | ||
| 534 | {liveDirect.map((agent) => ( | ||
| 535 | <AgentRow key={agent.id} agent={agent} /> | ||
| 536 | ))} | ||
| 537 | </section> | ||
| 538 | ) : null} | ||
| 539 | {settledWorkflows.length > 0 || settledDirect.length > 0 ? ( | ||
| 540 | <section> | ||
| 541 | <div className="px-1.5 pt-2 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground/70"> | ||
| 542 | Earlier | ||
| 543 | </div> | ||
| 544 | {settledWorkflows.map((group) => ( | ||
| 545 | <SettledWorkflowSection key={group.workflow.id} group={group} /> | ||
| 546 | ))} | ||
| 547 | {settledDirect.map((agent) => ( | ||
| 548 | <AgentRow key={agent.id} agent={agent} /> | ||
| 549 | ))} | ||
| 550 | </section> | ||
| 551 | ) : null} | ||
| 552 | </div> | ||
| 553 | </ScrollArea> | ||
| 554 | <footer className="flex items-center justify-between border-t border-border/60 px-3 py-1.5 font-mono text-[.7rem] text-muted-foreground"> | ||
| 555 | <span className="flex items-center gap-2"> | ||
| 556 | {model.runningCount + model.waitingCount > 0 ? ( | ||
| 557 | <span className="text-info-foreground"> | ||
| 558 | ● {model.runningCount + model.waitingCount} working | ||
| 559 | </span> | ||
| 560 | ) : null} | ||
| 561 | {model.idleCount > 0 ? <span>{model.idleCount} idle</span> : null} | ||
| 562 | {model.settledCount > 0 ? <span>{model.settledCount} settled</span> : null} | ||
| 563 | </span> | ||
| 564 | <span className="tabular-nums">Σ {formatSubagentTokenCount(model.totalTokens)} tok</span> | ||
| 565 | </footer> | ||
| 566 | </div> | ||
| 567 | ); | ||
| 568 | } | ||
apps/web/src/components/ChatView.tsx+115-1| ... | @@ -140,6 +140,11 @@ import { | ... | @@ -140,6 +140,11 @@ import { |
| 140 | usePreviewMiniPlayerStore, | 140 | usePreviewMiniPlayerStore, |
| 141 | } from "../previewMiniPlayerStore"; | 141 | } from "../previewMiniPlayerStore"; |
| 142 | import { RightPanelTabs } from "./RightPanelTabs"; | 142 | import { RightPanelTabs } from "./RightPanelTabs"; |
| 143 | import { AgentsPanel } from "./AgentsPanel"; | ||
| 144 | import { | ||
| 145 | deriveAgentPanelModel, | ||
| 146 | foldSubagentActivities, | ||
| 147 | } from "@t3tools/client-runtime/state/subagentRuntime"; | ||
| 143 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; | 148 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 144 | import { BranchToolbar } from "./BranchToolbar"; | 149 | import { BranchToolbar } from "./BranchToolbar"; |
| 145 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 150 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| ... | @@ -2052,6 +2057,18 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2052,6 +2057,18 @@ function ChatViewContent(props: ChatViewProps) { |
| 2052 | const phase = derivePhase(activeThread?.session ?? null); | 2057 | const phase = derivePhase(activeThread?.session ?? null); |
| 2053 | const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; | 2058 | const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; |
| 2054 | const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); | 2059 | const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); |
| 2060 | // Native subagent fold: memoized by activity-list identity, shared by the | ||
| 2061 | // Agents surface, live strip, and workflow cards. v2Projection is null | ||
| 2062 | // until orchestration-v2 lands (source precedence lives in the derive). | ||
| 2063 | // sessionLive derives interruption for agents orphaned by session death. | ||
| 2064 | const agentSessionLive = phase !== "disconnected"; | ||
| 2065 | const agentPanelModel = useMemo( | ||
| 2066 | () => | ||
| 2067 | deriveAgentPanelModel({ | ||
| 2068 | agents: foldSubagentActivities(threadActivities, { sessionLive: agentSessionLive }), | ||
| 2069 | }), | ||
| 2070 | [agentSessionLive, threadActivities], | ||
| 2071 | ); | ||
| 2055 | const pendingApprovals = useMemo( | 2072 | const pendingApprovals = useMemo( |
| 2056 | () => derivePendingApprovals(threadActivities), | 2073 | () => derivePendingApprovals(threadActivities), |
| 2057 | [threadActivities], | 2074 | [threadActivities], |
| ... | @@ -3134,6 +3151,10 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3134,6 +3151,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 3134 | if (!activeThreadRef || !activeProject) return; | 3151 | if (!activeThreadRef || !activeProject) return; |
| 3135 | useRightPanelStore.getState().open(activeThreadRef, "files"); | 3152 | useRightPanelStore.getState().open(activeThreadRef, "files"); |
| 3136 | }, [activeProject, activeThreadRef]); | 3153 | }, [activeProject, activeThreadRef]); |
| 3154 | const addAgentsSurface = useCallback(() => { | ||
| 3155 | if (!activeThreadRef) return; | ||
| 3156 | useRightPanelStore.getState().open(activeThreadRef, "agents"); | ||
| 3157 | }, [activeThreadRef]); | ||
| 3137 | const openFileSurface = useCallback( | 3158 | const openFileSurface = useCallback( |
| 3138 | (relativePath: string) => { | 3159 | (relativePath: string) => { |
| 3139 | if (!activeThreadRef || !activeProject) return; | 3160 | if (!activeThreadRef || !activeProject) return; |
| ... | @@ -4167,6 +4188,85 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4167,6 +4188,85 @@ function ChatViewContent(props: ChatViewProps) { |
| 4167 | switchGitRef, | 4188 | switchGitRef, |
| 4168 | updateThreadMetadata, | 4189 | updateThreadMetadata, |
| 4169 | ]); | 4190 | ]); |
| 4191 | // Background work (subagent fleets, workflow runs, watch loops) can outlive | ||
| 4192 | // the turn; once it settles, the composer stop button is gone, so this | ||
| 4193 | // banner is the only visible stop affordance. Stop routes through the | ||
| 4194 | // stop-everything interrupt: it kills every live background task before | ||
| 4195 | // interrupting, and works by session, so no active turn is needed. | ||
| 4196 | const activeBackgroundLiveness = | ||
| 4197 | !isWorking && activeThread ? (activeThreadShell?.backgroundLiveness ?? null) : null; | ||
| 4198 | const [isStoppingBackgroundWork, setIsStoppingBackgroundWork] = useState(false); | ||
| 4199 | useEffect(() => { | ||
| 4200 | // "Stopping..." holds until the liveness clears; the interrupt command | ||
| 4201 | // returning only means the request was accepted. | ||
| 4202 | if (activeBackgroundLiveness === null) { | ||
| 4203 | setIsStoppingBackgroundWork(false); | ||
| 4204 | } | ||
| 4205 | }, [activeBackgroundLiveness]); | ||
| 4206 | useEffect(() => { | ||
| 4207 | // Per-thread state: switching threads while A's stop is pending must not | ||
| 4208 | // disable B's Stop button (review finding). | ||
| 4209 | setIsStoppingBackgroundWork(false); | ||
| 4210 | }, [activeThreadId]); | ||
| 4211 | const handleStopBackgroundWork = useCallback(async () => { | ||
| 4212 | if (!activeThread) return; | ||
| 4213 | setIsStoppingBackgroundWork(true); | ||
| 4214 | const result = await interruptThreadTurn({ | ||
| 4215 | environmentId, | ||
| 4216 | input: buildThreadTurnInterruptInput(activeThread), | ||
| 4217 | }); | ||
| 4218 | if (result._tag === "Failure") { | ||
| 4219 | // Every failure clears the pending state — an interrupted command | ||
| 4220 | // never reached the server, so liveness would hold "Stopping..." | ||
| 4221 | // forever. Only real failures toast. | ||
| 4222 | setIsStoppingBackgroundWork(false); | ||
| 4223 | if (!isAtomCommandInterrupted(result)) { | ||
| 4224 | const error = squashAtomCommandFailure(result); | ||
| 4225 | setThreadError( | ||
| 4226 | activeThread.id, | ||
| 4227 | error instanceof Error ? error.message : "Failed to stop background work.", | ||
| 4228 | ); | ||
| 4229 | } | ||
| 4230 | } | ||
| 4231 | }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); | ||
| 4232 | const backgroundLivenessBannerItem = useMemo<ComposerBannerStackItem | null>(() => { | ||
| 4233 | if (activeBackgroundLiveness === null || !activeThread) { | ||
| 4234 | return null; | ||
| 4235 | } | ||
| 4236 | const working = activeBackgroundLiveness === "working"; | ||
| 4237 | const liveCount = agentPanelModel.liveCount; | ||
| 4238 | return { | ||
| 4239 | id: `background-liveness:${activeThread.id}`, | ||
| 4240 | variant: "default", | ||
| 4241 | icon: ( | ||
| 4242 | <span | ||
| 4243 | className={cn("size-1.5 rounded-full bg-foreground", working && "animate-status-pulse")} | ||
| 4244 | aria-hidden="true" | ||
| 4245 | /> | ||
| 4246 | ), | ||
| 4247 | title: working | ||
| 4248 | ? liveCount > 0 | ||
| 4249 | ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working in the background` | ||
| 4250 | : "Background work running" | ||
| 4251 | : "Monitoring in the background", | ||
| 4252 | actions: ( | ||
| 4253 | <Button | ||
| 4254 | size="xs" | ||
| 4255 | variant="outline" | ||
| 4256 | disabled={isStoppingBackgroundWork} | ||
| 4257 | onClick={() => void handleStopBackgroundWork()} | ||
| 4258 | > | ||
| 4259 | {isStoppingBackgroundWork ? "Stopping..." : "Stop"} | ||
| 4260 | </Button> | ||
| 4261 | ), | ||
| 4262 | }; | ||
| 4263 | }, [ | ||
| 4264 | activeBackgroundLiveness, | ||
| 4265 | activeThread, | ||
| 4266 | agentPanelModel.liveCount, | ||
| 4267 | handleStopBackgroundWork, | ||
| 4268 | isStoppingBackgroundWork, | ||
| 4269 | ]); | ||
| 4170 | // The stack renders items[0] front-most and tucks the rest behind hover, so | 4270 | // The stack renders items[0] front-most and tucks the rest behind hover, so |
| 4171 | // ordering is priority: system banners, then the branch-mismatch notice, | 4271 | // ordering is priority: system banners, then the branch-mismatch notice, |
| 4172 | // and the informational parked-thread banner last — it must never cover another. | 4272 | // and the informational parked-thread banner last — it must never cover another. |
| ... | @@ -4219,12 +4319,15 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4219,12 +4319,15 @@ function ChatViewContent(props: ChatViewProps) { |
| 4219 | void handleSwitchCheckoutToThread(); | 4319 | void handleSwitchCheckoutToThread(); |
| 4220 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); | 4320 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); |
| 4221 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { | 4321 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 4322 | const backgroundLivenessItems = | ||
| 4323 | backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; | ||
| 4222 | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; | 4324 | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; |
| 4223 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { | 4325 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| 4224 | return [...systemComposerBannerItems, ...parkedThreadItems]; | 4326 | return [...systemComposerBannerItems, ...backgroundLivenessItems, ...parkedThreadItems]; |
| 4225 | } | 4327 | } |
| 4226 | return [ | 4328 | return [ |
| 4227 | ...systemComposerBannerItems, | 4329 | ...systemComposerBannerItems, |
| 4330 | ...backgroundLivenessItems, | ||
| 4228 | { | 4331 | { |
| 4229 | id: `branch-mismatch:${activeBranchMismatchKey}`, | 4332 | id: `branch-mismatch:${activeBranchMismatchKey}`, |
| 4230 | variant: "info", | 4333 | variant: "info", |
| ... | @@ -4268,6 +4371,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4268,6 +4371,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4268 | ]; | 4371 | ]; |
| 4269 | }, [ | 4372 | }, [ |
| 4270 | activeBranchMismatchKey, | 4373 | activeBranchMismatchKey, |
| 4374 | backgroundLivenessBannerItem, | ||
| 4271 | handleRestoreThreadBranch, | 4375 | handleRestoreThreadBranch, |
| 4272 | isRestoringThreadBranch, | 4376 | isRestoringThreadBranch, |
| 4273 | localCheckoutBranchMismatch, | 4377 | localCheckoutBranchMismatch, |
| ... | @@ -5764,6 +5868,12 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5764,6 +5868,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 5764 | timestampFormat={timestampFormat} | 5868 | timestampFormat={timestampFormat} |
| 5765 | mode="embedded" | 5869 | mode="embedded" |
| 5766 | /> | 5870 | /> |
| 5871 | ) : activeRightPanelSurface?.kind === "agents" ? ( | ||
| 5872 | <AgentsPanel | ||
| 5873 | model={agentPanelModel} | ||
| 5874 | environmentId={activeThreadRef?.environmentId ?? null} | ||
| 5875 | threadId={activeThreadRef?.threadId ?? null} | ||
| 5876 | /> | ||
| 5767 | ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && | 5877 | ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && |
| 5768 | activeProject && | 5878 | activeProject && |
| 5769 | activeWorkspaceRoot ? ( | 5879 | activeWorkspaceRoot ? ( |
| ... | @@ -5859,6 +5969,8 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5859,6 +5969,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 5859 | <div className="relative flex min-h-0 flex-1 flex-col"> | 5969 | <div className="relative flex min-h-0 flex-1 flex-col"> |
| 5860 | {/* Messages — LegendList handles virtualization and scrolling internally */} | 5970 | {/* Messages — LegendList handles virtualization and scrolling internally */} |
| 5861 | <MessagesTimeline | 5971 | <MessagesTimeline |
| 5972 | agentPanelModel={agentPanelModel} | ||
| 5973 | onOpenAgents={addAgentsSurface} | ||
| 5862 | key={activeThread.id} | 5974 | key={activeThread.id} |
| 5863 | isWorking={isWorking} | 5975 | isWorking={isWorking} |
| 5864 | activeTurnInProgress={isWorking || !latestTurnSettled} | 5976 | activeTurnInProgress={isWorking || !latestTurnSettled} |
| ... | @@ -6195,6 +6307,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6195,6 +6307,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6195 | onAddTerminal={addTerminalSurface} | 6307 | onAddTerminal={addTerminalSurface} |
| 6196 | onAddDiff={addDiffSurface} | 6308 | onAddDiff={addDiffSurface} |
| 6197 | onAddFiles={addFilesSurface} | 6309 | onAddFiles={addFilesSurface} |
| 6310 | onAddAgents={addAgentsSurface} | ||
| 6198 | browserAvailable={isPreviewSupportedInRuntime()} | 6311 | browserAvailable={isPreviewSupportedInRuntime()} |
| 6199 | diffAvailable={isServerThread && isGitRepo} | 6312 | diffAvailable={isServerThread && isGitRepo} |
| 6200 | filesAvailable={activeProject !== null} | 6313 | filesAvailable={activeProject !== null} |
| ... | @@ -6222,6 +6335,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6222,6 +6335,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6222 | onAddTerminal={addTerminalSurface} | 6335 | onAddTerminal={addTerminalSurface} |
| 6223 | onAddDiff={addDiffSurface} | 6336 | onAddDiff={addDiffSurface} |
| 6224 | onAddFiles={addFilesSurface} | 6337 | onAddFiles={addFilesSurface} |
| 6338 | onAddAgents={addAgentsSurface} | ||
| 6225 | browserAvailable={isPreviewSupportedInRuntime()} | 6339 | browserAvailable={isPreviewSupportedInRuntime()} |
| 6226 | diffAvailable={isServerThread && isGitRepo} | 6340 | diffAvailable={isServerThread && isGitRepo} |
| 6227 | filesAvailable={activeProject !== null} | 6341 | filesAvailable={activeProject !== null} |
apps/web/src/components/RightPanelTabs.tsx+20-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; | 1 | import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; |
| 2 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | 2 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 3 | import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; | 3 | import { Bot, ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; |
| 4 | import { | 4 | import { |
| 5 | type MouseEvent as ReactMouseEvent, | 5 | type MouseEvent as ReactMouseEvent, |
| 6 | type ReactElement, | 6 | type ReactElement, |
| ... | @@ -44,6 +44,7 @@ interface RightPanelTabsProps { | ... | @@ -44,6 +44,7 @@ interface RightPanelTabsProps { |
| 44 | onAddTerminal: () => void; | 44 | onAddTerminal: () => void; |
| 45 | onAddDiff: () => void; | 45 | onAddDiff: () => void; |
| 46 | onAddFiles: () => void; | 46 | onAddFiles: () => void; |
| 47 | onAddAgents: () => void; | ||
| 47 | browserAvailable: boolean; | 48 | browserAvailable: boolean; |
| 48 | diffAvailable: boolean; | 49 | diffAvailable: boolean; |
| 49 | filesAvailable: boolean; | 50 | filesAvailable: boolean; |
| ... | @@ -91,6 +92,7 @@ function RightPanelEmptyState(props: { | ... | @@ -91,6 +92,7 @@ function RightPanelEmptyState(props: { |
| 91 | onAddTerminal: () => void; | 92 | onAddTerminal: () => void; |
| 92 | onAddDiff: () => void; | 93 | onAddDiff: () => void; |
| 93 | onAddFiles: () => void; | 94 | onAddFiles: () => void; |
| 95 | onAddAgents: () => void; | ||
| 94 | browserAvailable: boolean; | 96 | browserAvailable: boolean; |
| 95 | diffAvailable: boolean; | 97 | diffAvailable: boolean; |
| 96 | filesAvailable: boolean; | 98 | filesAvailable: boolean; |
| ... | @@ -128,6 +130,14 @@ function RightPanelEmptyState(props: { | ... | @@ -128,6 +130,14 @@ function RightPanelEmptyState(props: { |
| 128 | disabledReason: SURFACE_DISABLED_REASONS.diff, | 130 | disabledReason: SURFACE_DISABLED_REASONS.diff, |
| 129 | onClick: props.onAddDiff, | 131 | onClick: props.onAddDiff, |
| 130 | }, | 132 | }, |
| 133 | { | ||
| 134 | label: "Agents", | ||
| 135 | description: "Watch subagents and workflows run.", | ||
| 136 | icon: Bot, | ||
| 137 | available: true, | ||
| 138 | disabledReason: null, | ||
| 139 | onClick: props.onAddAgents, | ||
| 140 | }, | ||
| 131 | ] as const; | 141 | ] as const; |
| 132 | 142 | ||
| 133 | return ( | 143 | return ( |
| ... | @@ -205,6 +215,8 @@ function surfaceTitle( | ... | @@ -205,6 +215,8 @@ function surfaceTitle( |
| 205 | ); | 215 | ); |
| 206 | case "plan": | 216 | case "plan": |
| 207 | return "Plan"; | 217 | return "Plan"; |
| 218 | case "agents": | ||
| 219 | return "Agents"; | ||
| 208 | case "preview": { | 220 | case "preview": { |
| 209 | const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; | 221 | const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; |
| 210 | if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; | 222 | if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; |
| ... | @@ -266,6 +278,8 @@ function SurfaceIcon({ | ... | @@ -266,6 +278,8 @@ function SurfaceIcon({ |
| 266 | return <TerminalSquare className="size-3 shrink-0" />; | 278 | return <TerminalSquare className="size-3 shrink-0" />; |
| 267 | case "plan": | 279 | case "plan": |
| 268 | return <ClipboardList className="size-3 shrink-0" />; | 280 | return <ClipboardList className="size-3 shrink-0" />; |
| 281 | case "agents": | ||
| 282 | return <Bot className="size-3 shrink-0" />; | ||
| 269 | } | 283 | } |
| 270 | } | 284 | } |
| 271 | 285 | ||
| ... | @@ -466,6 +480,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -466,6 +480,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 466 | <FileDiff /> | 480 | <FileDiff /> |
| 467 | Diff | 481 | Diff |
| 468 | </SurfaceMenuItem> | 482 | </SurfaceMenuItem> |
| 483 | <SurfaceMenuItem available onClick={props.onAddAgents}> | ||
| 484 | <Bot /> | ||
| 485 | Agents | ||
| 486 | </SurfaceMenuItem> | ||
| 469 | </MenuPopup> | 487 | </MenuPopup> |
| 470 | </Menu> | 488 | </Menu> |
| 471 | ) : null} | 489 | ) : null} |
| ... | @@ -480,6 +498,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -480,6 +498,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 480 | onAddTerminal={props.onAddTerminal} | 498 | onAddTerminal={props.onAddTerminal} |
| 481 | onAddDiff={props.onAddDiff} | 499 | onAddDiff={props.onAddDiff} |
| 482 | onAddFiles={props.onAddFiles} | 500 | onAddFiles={props.onAddFiles} |
| 501 | onAddAgents={props.onAddAgents} | ||
| 483 | browserAvailable={props.browserAvailable} | 502 | browserAvailable={props.browserAvailable} |
| 484 | diffAvailable={props.diffAvailable} | 503 | diffAvailable={props.diffAvailable} |
| 485 | filesAvailable={props.filesAvailable} | 504 | filesAvailable={props.filesAvailable} |
apps/web/src/components/Sidebar.logic.ts+47-7| ... | @@ -120,6 +120,7 @@ export function buildBulkTitleRegenerationContextMenuItem(input: { | ... | @@ -120,6 +120,7 @@ export function buildBulkTitleRegenerationContextMenuItem(input: { |
| 120 | export interface ThreadStatusPill { | 120 | export interface ThreadStatusPill { |
| 121 | label: | 121 | label: |
| 122 | | "Working" | 122 | | "Working" |
| 123 | | "Monitoring" | ||
| 123 | | "Connecting" | 124 | | "Connecting" |
| 124 | | "Completed" | 125 | | "Completed" |
| 125 | | "Pending Approval" | 126 | | "Pending Approval" |
| ... | @@ -130,12 +131,16 @@ export interface ThreadStatusPill { | ... | @@ -130,12 +131,16 @@ export interface ThreadStatusPill { |
| 130 | pulse: boolean; | 131 | pulse: boolean; |
| 131 | } | 132 | } |
| 132 | 133 | ||
| 134 | // Rollup order mirrors the per-thread resolver exactly: attention states, | ||
| 135 | // then active work, then the actionable plan prompt, then passive | ||
| 136 | // monitoring. A Monitoring sibling must never hide a Plan Ready thread. | ||
| 133 | const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = { | 137 | const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = { |
| 134 | "Pending Approval": 5, | 138 | "Pending Approval": 6, |
| 135 | "Awaiting Input": 4, | 139 | "Awaiting Input": 5, |
| 136 | Working: 3, | 140 | Working: 4, |
| 137 | Connecting: 3, | 141 | Connecting: 4, |
| 138 | "Plan Ready": 2, | 142 | "Plan Ready": 3, |
| 143 | Monitoring: 2, | ||
| 139 | Completed: 1, | 144 | Completed: 1, |
| 140 | }; | 145 | }; |
| 141 | 146 | ||
| ... | @@ -147,6 +152,7 @@ type ThreadStatusInput = Pick< | ... | @@ -147,6 +152,7 @@ type ThreadStatusInput = Pick< |
| 147 | | "interactionMode" | 152 | | "interactionMode" |
| 148 | | "latestTurn" | 153 | | "latestTurn" |
| 149 | | "session" | 154 | | "session" |
| 155 | | "backgroundLiveness" | ||
| 150 | > & { | 156 | > & { |
| 151 | lastVisitedAt?: string | undefined; | 157 | lastVisitedAt?: string | undefined; |
| 152 | }; | 158 | }; |
| ... | @@ -424,11 +430,11 @@ export function resolveThreadRowClassName(input: { | ... | @@ -424,11 +430,11 @@ export function resolveThreadRowClassName(input: { |
| 424 | // whether it finished, asked a question, or proposed a plan. | 430 | // whether it finished, asked a question, or proposed a plan. |
| 425 | // Unread completion is tracked separately: it describes whether a ready | 431 | // Unread completion is tracked separately: it describes whether a ready |
| 426 | // thread needs attention, not what the thread is currently doing. | 432 | // thread needs attention, not what the thread is currently doing. |
| 427 | export type SidebarV2Status = "approval" | "input" | "working" | "failed" | "ready"; | 433 | export type SidebarV2Status = "approval" | "input" | "working" | "monitoring" | "failed" | "ready"; |
| 428 | 434 | ||
| 429 | type SidebarV2StatusInput = Pick< | 435 | type SidebarV2StatusInput = Pick< |
| 430 | SidebarThreadSummary, | 436 | SidebarThreadSummary, |
| 431 | "hasPendingApprovals" | "hasPendingUserInput" | "session" | 437 | "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness" |
| 432 | >; | 438 | >; |
| 433 | 439 | ||
| 434 | export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status { | 440 | export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status { |
| ... | @@ -441,9 +447,19 @@ export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2S | ... | @@ -441,9 +447,19 @@ export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2S |
| 441 | if (thread.session?.status === "running" || thread.session?.status === "starting") { | 447 | if (thread.session?.status === "running" || thread.session?.status === "starting") { |
| 442 | return "working"; | 448 | return "working"; |
| 443 | } | 449 | } |
| 450 | // A failed session outranks lingering background liveness: the user must | ||
| 451 | // see the failure, not a stale Working (review finding). | ||
| 444 | if (thread.session?.status === "error") { | 452 | if (thread.session?.status === "error") { |
| 445 | return "failed"; | 453 | return "failed"; |
| 446 | } | 454 | } |
| 455 | // Background work outlives the turn: fleets read as working; monitoring | ||
| 456 | // only when watch loops are the sole live work. | ||
| 457 | if (thread.backgroundLiveness === "working") { | ||
| 458 | return "working"; | ||
| 459 | } | ||
| 460 | if (thread.backgroundLiveness === "monitoring") { | ||
| 461 | return "monitoring"; | ||
| 462 | } | ||
| 447 | return "ready"; | 463 | return "ready"; |
| 448 | } | 464 | } |
| 449 | 465 | ||
| ... | @@ -616,6 +632,8 @@ export function resolveThreadStatusPill(input: { | ... | @@ -616,6 +632,8 @@ export function resolveThreadStatusPill(input: { |
| 616 | }; | 632 | }; |
| 617 | } | 633 | } |
| 618 | 634 | ||
| 635 | // An actionable plan prompt outranks lingering background work: it needs | ||
| 636 | // the user's decision, while liveness merely reports (review finding). | ||
| 619 | const hasPlanReadyPrompt = | 637 | const hasPlanReadyPrompt = |
| 620 | !thread.hasPendingUserInput && | 638 | !thread.hasPendingUserInput && |
| 621 | thread.interactionMode === "plan" && | 639 | thread.interactionMode === "plan" && |
| ... | @@ -630,6 +648,28 @@ export function resolveThreadStatusPill(input: { | ... | @@ -630,6 +648,28 @@ export function resolveThreadStatusPill(input: { |
| 630 | }; | 648 | }; |
| 631 | } | 649 | } |
| 632 | 650 | ||
| 651 | // The turn can settle while native background work runs on. Subagent and | ||
| 652 | // workflow fleets read as plain Working; Monitoring is reserved for watch | ||
| 653 | // loops (a parent agent babysitting a PR, tailing checks) with no other | ||
| 654 | // live work. Same recede treatment as Working per inbox-zero. | ||
| 655 | if (thread.backgroundLiveness === "working") { | ||
| 656 | return { | ||
| 657 | label: "Working", | ||
| 658 | colorClass: "text-sky-600 dark:text-sky-300/80", | ||
| 659 | dotClass: "bg-sky-500 dark:bg-sky-300/80", | ||
| 660 | pulse: true, | ||
| 661 | }; | ||
| 662 | } | ||
| 663 | |||
| 664 | if (thread.backgroundLiveness === "monitoring") { | ||
| 665 | return { | ||
| 666 | label: "Monitoring", | ||
| 667 | colorClass: "text-sky-600 dark:text-sky-300/80", | ||
| 668 | dotClass: "bg-sky-500 dark:bg-sky-300/80", | ||
| 669 | pulse: false, | ||
| 670 | }; | ||
| 671 | } | ||
| 672 | |||
| 633 | if (hasUnseenCompletion(thread)) { | 673 | if (hasUnseenCompletion(thread)) { |
| 634 | return { | 674 | return { |
| 635 | label: "Completed", | 675 | label: "Completed", |
apps/web/src/components/SidebarV2.tsx+28-19| ... | @@ -482,7 +482,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -482,7 +482,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 482 | // findable. In-flight rows recede the same as read-ready ones (inbox-zero: | 482 | // findable. In-flight rows recede the same as read-ready ones (inbox-zero: |
| 483 | // working threads aren't your problem yet) — only the colored status label | 483 | // working threads aren't your problem yet) — only the colored status label |
| 484 | // stands out. | 484 | // stands out. |
| 485 | const isInFlight = status === "working" || status === "approval" || status === "input"; | 485 | const isInFlight = |
| 486 | status === "working" || status === "monitoring" || status === "approval" || status === "input"; | ||
| 486 | const shouldRecede = | 487 | const shouldRecede = |
| 487 | (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; | 488 | (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; |
| 488 | // Status hues follow the system-wide convention set by sidebar v1 and the | 489 | // Status hues follow the system-wide convention set by sidebar v1 and the |
| ... | @@ -496,37 +497,45 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -496,37 +497,45 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 496 | className: | 497 | className: |
| 497 | "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400", | 498 | "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400", |
| 498 | } | 499 | } |
| 499 | : status === "approval" | 500 | : status === "monitoring" |
| 500 | ? { | 501 | ? { |
| 501 | label: "Approval", | 502 | // Steady label, no duty-cycled shimmer: monitoring is calm |
| 503 | // background presence, not active progress (monitoring-pill D6). | ||
| 504 | label: "Monitoring", | ||
| 502 | icon: null, | 505 | icon: null, |
| 503 | className: "text-amber-700 dark:text-amber-300", | 506 | className: "text-sky-600 dark:text-sky-400", |
| 504 | } | 507 | } |
| 505 | : status === "input" | 508 | : status === "approval" |
| 506 | ? { | 509 | ? { |
| 507 | label: "Input", | 510 | label: "Approval", |
| 508 | icon: null, | 511 | icon: null, |
| 509 | className: "text-indigo-600 dark:text-indigo-300", | 512 | className: "text-amber-700 dark:text-amber-300", |
| 510 | } | 513 | } |
| 511 | : status === "failed" | 514 | : status === "input" |
| 512 | ? { | 515 | ? { |
| 513 | label: "Failed", | 516 | label: "Input", |
| 514 | icon: null, | 517 | icon: null, |
| 515 | className: "text-red-700 dark:text-red-300", | 518 | className: "text-indigo-600 dark:text-indigo-300", |
| 516 | } | 519 | } |
| 517 | : isWoke | 520 | : status === "failed" |
| 518 | ? { | 521 | ? { |
| 519 | label: "Woke", | 522 | label: "Failed", |
| 520 | icon: "woke" as const, | 523 | icon: null, |
| 521 | className: "text-amber-700 dark:text-amber-300", | 524 | className: "text-red-700 dark:text-red-300", |
| 522 | } | 525 | } |
| 523 | : isUnread | 526 | : isWoke |
| 524 | ? { | 527 | ? { |
| 525 | label: "Done", | 528 | label: "Woke", |
| 526 | icon: "done" as const, | 529 | icon: "woke" as const, |
| 527 | className: "text-emerald-700 dark:text-emerald-300", | 530 | className: "text-amber-700 dark:text-amber-300", |
| 528 | } | 531 | } |
| 529 | : null; | 532 | : isUnread |
| 533 | ? { | ||
| 534 | label: "Done", | ||
| 535 | icon: "done" as const, | ||
| 536 | className: "text-emerald-700 dark:text-emerald-300", | ||
| 537 | } | ||
| 538 | : null; | ||
| 530 | 539 | ||
| 531 | const gitCwd = thread.worktreePath ?? props.projectCwd; | 540 | const gitCwd = thread.worktreePath ?? props.projectCwd; |
| 532 | const gitStatus = useEnvironmentQuery( | 541 | const gitStatus = useEnvironmentQuery( |
apps/web/src/components/chat/MessagesTimeline.logic.ts+37-14| ... | @@ -352,9 +352,16 @@ function deriveTurnFolds(input: { | ... | @@ -352,9 +352,16 @@ function deriveTurnFolds(input: { |
| 352 | } | 352 | } |
| 353 | const hiddenEntryIds = new Set<string>(); | 353 | const hiddenEntryIds = new Set<string>(); |
| 354 | for (const entry of group.entries) { | 354 | for (const entry of group.entries) { |
| 355 | if (entry.id !== group.terminalEntry?.id) { | 355 | if (entry.id === group.terminalEntry?.id) { |
| 356 | hiddenEntryIds.add(entry.id); | 356 | continue; |
| 357 | } | 357 | } |
| 358 | // Agent-spawn CTA rows never fold: workflows outlive their launching | ||
| 359 | // turn (dynamic spawns, background execution), and folding the CTA | ||
| 360 | // when the turn settles makes a still-running fleet invisible. | ||
| 361 | if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { | ||
| 362 | continue; | ||
| 363 | } | ||
| 364 | hiddenEntryIds.add(entry.id); | ||
| 358 | } | 365 | } |
| 359 | if (hiddenEntryIds.size === 0) { | 366 | if (hiddenEntryIds.size === 0) { |
| 360 | continue; | 367 | continue; |
| ... | @@ -489,9 +496,21 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -489,9 +496,21 @@ export function deriveMessagesTimelineRows(input: { |
| 489 | } else { | 496 | } else { |
| 490 | const groupId = `work-group:${timelineEntry.id}`; | 497 | const groupId = `work-group:${timelineEntry.id}`; |
| 491 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; | 498 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; |
| 492 | const hiddenEntries = visibleGroupedEntries.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES); | 499 | // Agent-spawn CTA rows are always visible: a running fleet must |
| 493 | const visibleEntries = visibleGroupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES); | 500 | // never hide behind a "+N tool calls" toggle. Selection is by |
| 494 | const renderedEntries = expanded ? [...hiddenEntries, ...visibleEntries] : visibleEntries; | 501 | // membership (spawn OR recent-tail), preserving the group's |
| 502 | // chronological order in both collapsed and expanded states | ||
| 503 | // (review finding: concatenating two filtered lists moved a | ||
| 504 | // mid-group spawn row above earlier tool rows). | ||
| 505 | const overflowCandidates = visibleGroupedEntries.filter( | ||
| 506 | (entry) => entry.agentSpawn === undefined, | ||
| 507 | ); | ||
| 508 | const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES); | ||
| 509 | const hiddenIds = new Set(hiddenEntries.map((entry) => entry.id)); | ||
| 510 | const visibleEntries = visibleGroupedEntries.filter( | ||
| 511 | (entry) => entry.agentSpawn !== undefined || !hiddenIds.has(entry.id), | ||
| 512 | ); | ||
| 513 | const renderedEntries = expanded ? visibleGroupedEntries : visibleEntries; | ||
| 495 | 514 | ||
| 496 | for (const workEntry of renderedEntries) { | 515 | for (const workEntry of renderedEntries) { |
| 497 | nextRows.push({ | 516 | nextRows.push({ |
| ... | @@ -502,15 +521,19 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -502,15 +521,19 @@ export function deriveMessagesTimelineRows(input: { |
| 502 | }); | 521 | }); |
| 503 | } | 522 | } |
| 504 | 523 | ||
| 505 | nextRows.push({ | 524 | if (hiddenEntries.length > 0) { |
| 506 | kind: "work-toggle", | 525 | nextRows.push({ |
| 507 | id: `work-toggle:${timelineEntry.id}`, | 526 | kind: "work-toggle", |
| 508 | createdAt: timelineEntry.createdAt, | 527 | id: `work-toggle:${timelineEntry.id}`, |
| 509 | groupId, | 528 | createdAt: timelineEntry.createdAt, |
| 510 | hiddenCount: hiddenEntries.length, | 529 | groupId, |
| 511 | expanded, | 530 | hiddenCount: hiddenEntries.length, |
| 512 | onlyToolEntries: visibleGroupedEntries.every((entry) => workLogEntryIsToolLike(entry)), | 531 | expanded, |
| 513 | }); | 532 | onlyToolEntries: visibleGroupedEntries.every((entry) => |
| 533 | workLogEntryIsToolLike(entry), | ||
| 534 | ), | ||
| 535 | }); | ||
| 536 | } | ||
| 514 | } | 537 | } |
| 515 | } | 538 | } |
| 516 | index = cursor - 1; | 539 | index = cursor - 1; |
apps/web/src/components/chat/MessagesTimeline.tsx+131-1| ... | @@ -6,6 +6,14 @@ import { | ... | @@ -6,6 +6,14 @@ import { |
| 6 | type TurnId, | 6 | type TurnId, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | 8 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 9 | import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; | ||
| 10 | import { | ||
| 11 | emptyAgentPanelModel, | ||
| 12 | formatSubagentTokenCount, | ||
| 13 | } from "@t3tools/client-runtime/state/subagentRuntime"; | ||
| 14 | |||
| 15 | const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); | ||
| 16 | const NOOP_OPEN_AGENTS = () => {}; | ||
| 9 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 17 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 10 | import { | 18 | import { |
| 11 | createContext, | 19 | createContext, |
| ... | @@ -135,6 +143,8 @@ interface TimelineRowSharedState { | ... | @@ -135,6 +143,8 @@ interface TimelineRowSharedState { |
| 135 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 143 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 136 | onToggleTurnFold: (turnId: TurnId) => void; | 144 | onToggleTurnFold: (turnId: TurnId) => void; |
| 137 | onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; | 145 | onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; |
| 146 | agentPanelModel: AgentPanelModel; | ||
| 147 | onOpenAgents: () => void; | ||
| 138 | } | 148 | } |
| 139 | 149 | ||
| 140 | interface TimelineRowActivityState { | 150 | interface TimelineRowActivityState { |
| ... | @@ -156,6 +166,8 @@ const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "d | ... | @@ -156,6 +166,8 @@ const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "d |
| 156 | // --------------------------------------------------------------------------- | 166 | // --------------------------------------------------------------------------- |
| 157 | 167 | ||
| 158 | interface MessagesTimelineProps { | 168 | interface MessagesTimelineProps { |
| 169 | agentPanelModel?: AgentPanelModel; | ||
| 170 | onOpenAgents?: () => void; | ||
| 159 | isWorking: boolean; | 171 | isWorking: boolean; |
| 160 | activeTurnInProgress: boolean; | 172 | activeTurnInProgress: boolean; |
| 161 | activeTurnStartedAt: string | null; | 173 | activeTurnStartedAt: string | null; |
| ... | @@ -194,6 +206,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -194,6 +206,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 194 | isWorking, | 206 | isWorking, |
| 195 | activeTurnInProgress, | 207 | activeTurnInProgress, |
| 196 | activeTurnStartedAt, | 208 | activeTurnStartedAt, |
| 209 | agentPanelModel = EMPTY_AGENT_PANEL_MODEL, | ||
| 210 | onOpenAgents = NOOP_OPEN_AGENTS, | ||
| 197 | listRef, | 211 | listRef, |
| 198 | timelineEntries, | 212 | timelineEntries, |
| 199 | latestTurn, | 213 | latestTurn, |
| ... | @@ -430,6 +444,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -430,6 +444,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 430 | onOpenTurnDiff, | 444 | onOpenTurnDiff, |
| 431 | onToggleTurnFold, | 445 | onToggleTurnFold, |
| 432 | onToggleWorkGroup, | 446 | onToggleWorkGroup, |
| 447 | agentPanelModel, | ||
| 448 | onOpenAgents, | ||
| 433 | }), | 449 | }), |
| 434 | [ | 450 | [ |
| 435 | timestampFormat, | 451 | timestampFormat, |
| ... | @@ -444,6 +460,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -444,6 +460,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 444 | onOpenTurnDiff, | 460 | onOpenTurnDiff, |
| 445 | onToggleTurnFold, | 461 | onToggleTurnFold, |
| 446 | onToggleWorkGroup, | 462 | onToggleWorkGroup, |
| 463 | agentPanelModel, | ||
| 464 | onOpenAgents, | ||
| 447 | ], | 465 | ], |
| 448 | ); | 466 | ); |
| 449 | const activityState = useMemo<TimelineRowActivityState>( | 467 | const activityState = useMemo<TimelineRowActivityState>( |
| ... | @@ -1897,8 +1915,14 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { | ... | @@ -1897,8 +1915,14 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { |
| 1897 | case "mcp_tool_call": | 1915 | case "mcp_tool_call": |
| 1898 | return "wrench"; | 1916 | return "wrench"; |
| 1899 | case "dynamic_tool_call": | 1917 | case "dynamic_tool_call": |
| 1900 | case "collab_agent_tool_call": | ||
| 1901 | return "hammer"; | 1918 | return "hammer"; |
| 1919 | case "collab_agent_tool_call": | ||
| 1920 | return "bot"; | ||
| 1921 | } | ||
| 1922 | |||
| 1923 | // Subagent lifecycle rows (grouped by taskId) get agent identity chrome. | ||
| 1924 | if (workEntry.taskId) { | ||
| 1925 | return "bot"; | ||
| 1902 | } | 1926 | } |
| 1903 | 1927 | ||
| 1904 | return workToneIcon(workEntry.tone).iconName; | 1928 | return workToneIcon(workEntry.tone).iconName; |
| ... | @@ -1921,9 +1945,115 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { | ... | @@ -1921,9 +1945,115 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { |
| 1921 | 1945 | ||
| 1922 | const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); | 1946 | const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); |
| 1923 | 1947 | ||
| 1948 | /** | ||
| 1949 | * A1 spawn CTA: one anchored row per workflow run (or per-turn direct-spawn | ||
| 1950 | * batch). Live status is derived from the shared agent panel model at render | ||
| 1951 | * time — the row itself never re-renders a roster; the Agents panel is the | ||
| 1952 | * only roster. Freezes to past tense when every member settles. Static dot, | ||
| 1953 | * no animation. | ||
| 1954 | */ | ||
| 1955 | const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: TimelineWorkEntry }) { | ||
| 1956 | const { workEntry } = props; | ||
| 1957 | const { agentPanelModel, onOpenAgents } = use(TimelineRowCtx); | ||
| 1958 | const spawn = workEntry.agentSpawn; | ||
| 1959 | if (!spawn) { | ||
| 1960 | return null; | ||
| 1961 | } | ||
| 1962 | |||
| 1963 | const memberIds = new Set(spawn.agentTaskIds); | ||
| 1964 | const workflowGroup = spawn.workflowId | ||
| 1965 | ? agentPanelModel.workflows.find((group) => group.workflow.id === spawn.workflowId) | ||
| 1966 | : undefined; | ||
| 1967 | const agents = workflowGroup | ||
| 1968 | ? [...workflowGroup.phases.flatMap((phase) => phase.members), ...workflowGroup.unphasedMembers] | ||
| 1969 | : agentPanelModel.directAgents.filter((agent) => memberIds.has(agent.id)); | ||
| 1970 | const agentCount = Math.max( | ||
| 1971 | agents.length, | ||
| 1972 | Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), | ||
| 1973 | ); | ||
| 1974 | |||
| 1975 | const running = agents.filter( | ||
| 1976 | (agent) => agent.status === "running" || agent.status === "pending", | ||
| 1977 | ).length; | ||
| 1978 | const waiting = agents.filter((agent) => agent.status === "waiting").length; | ||
| 1979 | const failed = agents.filter((agent) => agent.status === "failed").length; | ||
| 1980 | // The coordinator's own status is authoritative for workflows: dynamic | ||
| 1981 | // spawns mean the member list can be momentarily all-settled while the | ||
| 1982 | // run is still mid-flight (the "completed" lie from live testing). A | ||
| 1983 | // workflow is live until the coordinator itself reaches a terminal state. | ||
| 1984 | const coordinatorStatus = workflowGroup?.workflow.status; | ||
| 1985 | const coordinatorSettled = | ||
| 1986 | coordinatorStatus === "completed" || | ||
| 1987 | coordinatorStatus === "failed" || | ||
| 1988 | coordinatorStatus === "cancelled" || | ||
| 1989 | coordinatorStatus === "interrupted"; | ||
| 1990 | const live = workflowGroup !== undefined ? !coordinatorSettled : running + waiting > 0; | ||
| 1991 | // Same rule as the panel footer: providers may aggregate member usage into | ||
| 1992 | // the coordinator, so count the coordinator only when no members exist. | ||
| 1993 | const totalTokens = agents.reduce( | ||
| 1994 | (sum, agent) => sum + (agent.usage?.totalTokens ?? 0), | ||
| 1995 | spawn.workflowId && agents.length === 0 ? (workflowGroup?.workflow.usage?.totalTokens ?? 0) : 0, | ||
| 1996 | ); | ||
| 1997 | |||
| 1998 | const livePhase = workflowGroup?.phases.find((phase) => phase.state === "running"); | ||
| 1999 | const workflowName = | ||
| 2000 | workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null; | ||
| 2001 | |||
| 2002 | // One steady in-flight presentation (monitoring-pill rule): waiting and | ||
| 2003 | // stalled agents read as working; only settled states differentiate. | ||
| 2004 | const working = running + waiting; | ||
| 2005 | const dotClass = live ? "bg-info" : failed > 0 ? "bg-destructive" : "bg-success"; | ||
| 2006 | const lead = live | ||
| 2007 | ? `Kicked off ${agentCount} subagent${agentCount === 1 ? "" : "s"}` | ||
| 2008 | : `Ran ${agentCount} subagent${agentCount === 1 ? "" : "s"}`; | ||
| 2009 | const status = live | ||
| 2010 | ? livePhase | ||
| 2011 | ? `${livePhase.title} · ${livePhase.activeCount} working` | ||
| 2012 | : working > 0 | ||
| 2013 | ? `${working} working` | ||
| 2014 | : "working" | ||
| 2015 | : failed > 0 | ||
| 2016 | ? `${failed} failed` | ||
| 2017 | : "✓ completed"; | ||
| 2018 | |||
| 2019 | return ( | ||
| 2020 | <button | ||
| 2021 | type="button" | ||
| 2022 | onClick={onOpenAgents} | ||
| 2023 | className="-mx-1 flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[13px] transition hover:bg-accent/50" | ||
| 2024 | > | ||
| 2025 | <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} /> | ||
| 2026 | <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" /> | ||
| 2027 | <span className="min-w-0 truncate"> | ||
| 2028 | <span className="font-medium">{lead}</span> | ||
| 2029 | {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null} | ||
| 2030 | </span> | ||
| 2031 | <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground"> | ||
| 2032 | <span>{status}</span> | ||
| 2033 | {totalTokens > 0 ? ( | ||
| 2034 | <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span> | ||
| 2035 | ) : null} | ||
| 2036 | <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span> | ||
| 2037 | </span> | ||
| 2038 | </button> | ||
| 2039 | ); | ||
| 2040 | }); | ||
| 2041 | |||
| 1924 | const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { | 2042 | const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 1925 | workEntry: TimelineWorkEntry; | 2043 | workEntry: TimelineWorkEntry; |
| 1926 | workspaceRoot: string | undefined; | 2044 | workspaceRoot: string | undefined; |
| 2045 | }) { | ||
| 2046 | const { workEntry, workspaceRoot } = props; | ||
| 2047 | // Before any hooks: spawn CTA rows render their own component. | ||
| 2048 | if (workEntry.agentSpawn) { | ||
| 2049 | return <AgentSpawnCtaRow workEntry={workEntry} />; | ||
| 2050 | } | ||
| 2051 | return <PlainWorkEntryRow workEntry={workEntry} workspaceRoot={workspaceRoot} />; | ||
| 2052 | }); | ||
| 2053 | |||
| 2054 | const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ||
| 2055 | workEntry: TimelineWorkEntry; | ||
| 2056 | workspaceRoot: string | undefined; | ||
| 1927 | }) { | 2057 | }) { |
| 1928 | const { workEntry, workspaceRoot } = props; | 2058 | const { workEntry, workspaceRoot } = props; |
| 1929 | const activity = use(TimelineRowActivityCtx); | 2059 | const activity = use(TimelineRowActivityCtx); |
apps/web/src/rightPanelStore.ts+14-3| ... | @@ -14,7 +14,15 @@ import { createJSONStorage, persist } from "zustand/middleware"; | ... | @@ -14,7 +14,15 @@ import { createJSONStorage, persist } from "zustand/middleware"; |
| 14 | 14 | ||
| 15 | import { resolveStorage } from "./lib/storage"; | 15 | import { resolveStorage } from "./lib/storage"; |
| 16 | 16 | ||
| 17 | export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const; | 17 | export const RIGHT_PANEL_KINDS = [ |
| 18 | "plan", | ||
| 19 | "diff", | ||
| 20 | "files", | ||
| 21 | "file", | ||
| 22 | "preview", | ||
| 23 | "terminal", | ||
| 24 | "agents", | ||
| 25 | ] as const; | ||
| 18 | export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; | 26 | export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; |
| 19 | 27 | ||
| 20 | export type RightPanelSurface = | 28 | export type RightPanelSurface = |
| ... | @@ -37,10 +45,11 @@ export type RightPanelSurface = | ... | @@ -37,10 +45,11 @@ export type RightPanelSurface = |
| 37 | revealLine: number | null; | 45 | revealLine: number | null; |
| 38 | revealRequestId: number; | 46 | revealRequestId: number; |
| 39 | } | 47 | } |
| 40 | | { id: "plan"; kind: "plan" }; | 48 | | { id: "plan"; kind: "plan" } |
| 49 | | { id: "agents"; kind: "agents" }; | ||
| 41 | 50 | ||
| 42 | const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; | 51 | const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; |
| 43 | const RIGHT_PANEL_STORAGE_VERSION = 7; | 52 | const RIGHT_PANEL_STORAGE_VERSION = 8; |
| 44 | 53 | ||
| 45 | export interface ThreadRightPanelState { | 54 | export interface ThreadRightPanelState { |
| 46 | isOpen: boolean; | 55 | isOpen: boolean; |
| ... | @@ -92,6 +101,8 @@ const singletonSurface = ( | ... | @@ -92,6 +101,8 @@ const singletonSurface = ( |
| 92 | return { id: "files", kind }; | 101 | return { id: "files", kind }; |
| 93 | case "plan": | 102 | case "plan": |
| 94 | return { id: "plan", kind }; | 103 | return { id: "plan", kind }; |
| 104 | case "agents": | ||
| 105 | return { id: "agents", kind }; | ||
| 95 | } | 106 | } |
| 96 | }; | 107 | }; |
| 97 | 108 |
apps/web/src/session-logic.test.ts+226-1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | classifyTaskAgentKind, | ||
| 2 | EventId, | 3 | EventId, |
| 3 | MessageId, | 4 | MessageId, |
| 4 | ThreadId, | 5 | ThreadId, |
| ... | @@ -35,7 +36,19 @@ function makeActivity(overrides: { | ... | @@ -35,7 +36,19 @@ function makeActivity(overrides: { |
| 35 | turnId?: string; | 36 | turnId?: string; |
| 36 | sequence?: number; | 37 | sequence?: number; |
| 37 | }): OrchestrationThreadActivity { | 38 | }): OrchestrationThreadActivity { |
| 38 | const payload = overrides.payload ?? {}; | 39 | // Fixtures model post-ingestion rows: ingestion stamps agentKind on every |
| 40 | // task.* payload. Pass an explicit agentKind to model legacy rows. | ||
| 41 | const rawPayload = overrides.payload ?? {}; | ||
| 42 | const payload = | ||
| 43 | overrides.kind?.startsWith("task.") && !("agentKind" in rawPayload) | ||
| 44 | ? { | ||
| 45 | ...rawPayload, | ||
| 46 | agentKind: classifyTaskAgentKind({ | ||
| 47 | taskType: typeof rawPayload.taskType === "string" ? rawPayload.taskType : undefined, | ||
| 48 | agentId: typeof rawPayload.agentId === "string" ? rawPayload.agentId : undefined, | ||
| 49 | }), | ||
| 50 | } | ||
| 51 | : rawPayload; | ||
| 39 | return { | 52 | return { |
| 40 | id: EventId.make(overrides.id ?? `activity-${nextActivityId++}`), | 53 | id: EventId.make(overrides.id ?? `activity-${nextActivityId++}`), |
| 41 | createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z", | 54 | createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z", |
| ... | @@ -1686,3 +1699,215 @@ describe("deriveActiveWorkStartedAt", () => { | ... | @@ -1686,3 +1699,215 @@ describe("deriveActiveWorkStartedAt", () => { |
| 1686 | ).toBe("2026-02-27T21:11:00.000Z"); | 1699 | ).toBe("2026-02-27T21:11:00.000Z"); |
| 1687 | }); | 1700 | }); |
| 1688 | }); | 1701 | }); |
| 1702 | |||
| 1703 | describe("deriveWorkLogEntries quiet-timeline guarantee", () => { | ||
| 1704 | it("N concurrent subagents produce exactly N lifecycle rows, zero attributed tool rows", () => { | ||
| 1705 | const activities: OrchestrationThreadActivity[] = []; | ||
| 1706 | for (let agent = 0; agent < 5; agent += 1) { | ||
| 1707 | const taskId = `task-${agent}`; | ||
| 1708 | // Progress ticks (several per agent) + attributed tool rows. | ||
| 1709 | for (let tick = 0; tick < 4; tick += 1) { | ||
| 1710 | activities.push( | ||
| 1711 | makeActivity({ | ||
| 1712 | kind: "task.progress", | ||
| 1713 | summary: `agent ${agent} tick ${tick}`, | ||
| 1714 | tone: "info", | ||
| 1715 | payload: { taskId, summary: `working ${tick}`, role: "explorer" }, | ||
| 1716 | turnId: "turn-batch", | ||
| 1717 | sequence: agent * 20 + tick, | ||
| 1718 | }), | ||
| 1719 | ); | ||
| 1720 | activities.push( | ||
| 1721 | makeActivity({ | ||
| 1722 | kind: "tool.completed", | ||
| 1723 | summary: "Read", | ||
| 1724 | payload: { itemType: "dynamic_tool_call", agentId: taskId }, | ||
| 1725 | sequence: agent * 20 + 10 + tick, | ||
| 1726 | }), | ||
| 1727 | ); | ||
| 1728 | } | ||
| 1729 | activities.push( | ||
| 1730 | makeActivity({ | ||
| 1731 | kind: "task.completed", | ||
| 1732 | summary: "Task completed", | ||
| 1733 | tone: "info", | ||
| 1734 | payload: { | ||
| 1735 | taskId, | ||
| 1736 | status: "completed", | ||
| 1737 | summary: `agent ${agent} done`, | ||
| 1738 | role: "explorer", | ||
| 1739 | }, | ||
| 1740 | turnId: "turn-batch", | ||
| 1741 | sequence: agent * 20 + 19, | ||
| 1742 | }), | ||
| 1743 | ); | ||
| 1744 | } | ||
| 1745 | |||
| 1746 | const entries = deriveWorkLogEntries(activities); | ||
| 1747 | // A1 CTA design: all direct spawns in one turn collapse into ONE | ||
| 1748 | // call-to-action row carrying the batch's agent ids. | ||
| 1749 | const spawnRows = entries.filter((entry) => entry.agentSpawn !== undefined); | ||
| 1750 | expect(spawnRows).toHaveLength(1); | ||
| 1751 | expect(spawnRows[0]!.agentSpawn!.agentTaskIds).toHaveLength(5); | ||
| 1752 | expect(spawnRows[0]!.agentSpawn!.workflowId).toBeNull(); | ||
| 1753 | // No agent-attributed tool rows leak into the main log. | ||
| 1754 | expect(entries.some((entry) => entry.sourceActivityKind?.startsWith("tool."))).toBe(false); | ||
| 1755 | }); | ||
| 1756 | |||
| 1757 | it("a workflow run and its members collapse into one CTA row keyed to the coordinator", () => { | ||
| 1758 | const entries = deriveWorkLogEntries([ | ||
| 1759 | makeActivity({ | ||
| 1760 | kind: "task.progress", | ||
| 1761 | summary: "coordinator", | ||
| 1762 | tone: "info", | ||
| 1763 | payload: { taskId: "wf-1", taskType: "local_workflow", workflowName: "math-check" }, | ||
| 1764 | sequence: 1, | ||
| 1765 | }), | ||
| 1766 | makeActivity({ | ||
| 1767 | kind: "task.progress", | ||
| 1768 | summary: "member", | ||
| 1769 | tone: "info", | ||
| 1770 | payload: { taskId: "wf-1:wf:0", status: "running", parentAgentId: "wf-1" }, | ||
| 1771 | sequence: 2, | ||
| 1772 | }), | ||
| 1773 | makeActivity({ | ||
| 1774 | kind: "task.completed", | ||
| 1775 | summary: "member done", | ||
| 1776 | tone: "info", | ||
| 1777 | payload: { taskId: "wf-1:wf:1", status: "completed", parentAgentId: "wf-1" }, | ||
| 1778 | sequence: 3, | ||
| 1779 | }), | ||
| 1780 | ]); | ||
| 1781 | const spawnRows = entries.filter((entry) => entry.agentSpawn !== undefined); | ||
| 1782 | expect(spawnRows).toHaveLength(1); | ||
| 1783 | expect(spawnRows[0]!.agentSpawn!.workflowId).toBe("wf-1"); | ||
| 1784 | expect(spawnRows[0]!.agentSpawn!.agentTaskIds).toEqual( | ||
| 1785 | expect.arrayContaining(["wf-1", "wf-1:wf:0", "wf-1:wf:1"]), | ||
| 1786 | ); | ||
| 1787 | }); | ||
| 1788 | |||
| 1789 | it("keeps unattributed tool rows (over-hiding loses the only signal)", () => { | ||
| 1790 | const entries = deriveWorkLogEntries([ | ||
| 1791 | makeActivity({ | ||
| 1792 | kind: "tool.completed", | ||
| 1793 | summary: "Bash", | ||
| 1794 | payload: { itemType: "command_execution", command: "ls" }, | ||
| 1795 | }), | ||
| 1796 | ]); | ||
| 1797 | expect(entries).toHaveLength(1); | ||
| 1798 | }); | ||
| 1799 | |||
| 1800 | it("folds timelineBypass agent rows into one CTA (Codex children, workflow members)", () => { | ||
| 1801 | // Codex children carry their parent's spawn turn (spawnTurnId stamping), | ||
| 1802 | // which is what batches a fleet into one CTA. | ||
| 1803 | const entries = deriveWorkLogEntries([ | ||
| 1804 | makeActivity({ | ||
| 1805 | kind: "task.progress", | ||
| 1806 | summary: "child work", | ||
| 1807 | tone: "info", | ||
| 1808 | payload: { taskId: "child-1", timelineBypass: true }, | ||
| 1809 | turnId: "turn-spawn", | ||
| 1810 | }), | ||
| 1811 | makeActivity({ | ||
| 1812 | kind: "task.progress", | ||
| 1813 | summary: "child work again", | ||
| 1814 | tone: "info", | ||
| 1815 | payload: { taskId: "child-2", timelineBypass: true }, | ||
| 1816 | turnId: "turn-spawn", | ||
| 1817 | }), | ||
| 1818 | ]); | ||
| 1819 | // Not suppressed outright (a Codex fleet's rows are ALL bypassed and | ||
| 1820 | // still need a CTA anchor) — but never more than the batch's single row. | ||
| 1821 | expect(entries).toHaveLength(1); | ||
| 1822 | expect(entries[0]!.agentSpawn?.agentTaskIds).toEqual(["child-1", "child-2"]); | ||
| 1823 | }); | ||
| 1824 | |||
| 1825 | it("timelineBypass non-agent rows (background shells) stay suppressed", () => { | ||
| 1826 | const entries = deriveWorkLogEntries([ | ||
| 1827 | makeActivity({ | ||
| 1828 | kind: "task.progress", | ||
| 1829 | summary: "stall", | ||
| 1830 | tone: "info", | ||
| 1831 | payload: { taskId: "sh-1", taskType: "local_bash", timelineBypass: true }, | ||
| 1832 | }), | ||
| 1833 | ]); | ||
| 1834 | expect(entries).toHaveLength(0); | ||
| 1835 | }); | ||
| 1836 | |||
| 1837 | it("drops task.updated and tool.progress from the work log (fold input only)", () => { | ||
| 1838 | const entries = deriveWorkLogEntries([ | ||
| 1839 | makeActivity({ | ||
| 1840 | kind: "task.updated", | ||
| 1841 | summary: "Task running", | ||
| 1842 | tone: "info", | ||
| 1843 | payload: { taskId: "task-1", status: "running" }, | ||
| 1844 | }), | ||
| 1845 | makeActivity({ | ||
| 1846 | kind: "tool.progress", | ||
| 1847 | summary: "Read", | ||
| 1848 | tone: "info", | ||
| 1849 | payload: { taskId: "task-1", toolName: "Read" }, | ||
| 1850 | }), | ||
| 1851 | ]); | ||
| 1852 | expect(entries).toHaveLength(0); | ||
| 1853 | }); | ||
| 1854 | }); | ||
| 1855 | |||
| 1856 | describe("rerun workflows", () => { | ||
| 1857 | it("turn-less direct spawns do not collapse into one global batch", () => { | ||
| 1858 | // Rows that lost their turn id (defensive path) group per task, so two | ||
| 1859 | // unrelated turn-less spawns never merge into one immortal CTA. | ||
| 1860 | const entries = deriveWorkLogEntries([ | ||
| 1861 | makeActivity({ | ||
| 1862 | kind: "task.started", | ||
| 1863 | summary: "Task started", | ||
| 1864 | payload: { taskId: "loose-1", taskType: "local_agent", role: "a" }, | ||
| 1865 | sequence: 1, | ||
| 1866 | }), | ||
| 1867 | makeActivity({ | ||
| 1868 | kind: "task.started", | ||
| 1869 | summary: "Task started", | ||
| 1870 | payload: { taskId: "loose-2", taskType: "local_agent", role: "b" }, | ||
| 1871 | sequence: 2, | ||
| 1872 | }), | ||
| 1873 | ]); | ||
| 1874 | const spawnRows = entries.filter((entry) => entry.agentSpawn !== undefined); | ||
| 1875 | expect(spawnRows).toHaveLength(2); | ||
| 1876 | expect(spawnRows.map((row) => row.agentSpawn!.agentTaskIds)).toEqual([ | ||
| 1877 | ["loose-1"], | ||
| 1878 | ["loose-2"], | ||
| 1879 | ]); | ||
| 1880 | }); | ||
| 1881 | |||
| 1882 | it("each workflow run gets its own CTA row (distinct coordinator ids)", () => { | ||
| 1883 | const entries = deriveWorkLogEntries([ | ||
| 1884 | makeActivity({ | ||
| 1885 | kind: "task.progress", | ||
| 1886 | summary: "run 1", | ||
| 1887 | tone: "info", | ||
| 1888 | payload: { taskId: "wf-run1", taskType: "local_workflow", workflowName: "math-check" }, | ||
| 1889 | turnId: "turn-1", | ||
| 1890 | sequence: 1, | ||
| 1891 | }), | ||
| 1892 | makeActivity({ | ||
| 1893 | kind: "task.completed", | ||
| 1894 | summary: "run 1 done", | ||
| 1895 | tone: "info", | ||
| 1896 | payload: { taskId: "wf-run1", status: "completed", taskType: "local_workflow" }, | ||
| 1897 | turnId: "turn-1", | ||
| 1898 | sequence: 2, | ||
| 1899 | }), | ||
| 1900 | makeActivity({ | ||
| 1901 | kind: "task.progress", | ||
| 1902 | summary: "run 2", | ||
| 1903 | tone: "info", | ||
| 1904 | payload: { taskId: "wf-run2", taskType: "local_workflow", workflowName: "math-check" }, | ||
| 1905 | turnId: "turn-2", | ||
| 1906 | sequence: 3, | ||
| 1907 | }), | ||
| 1908 | ]); | ||
| 1909 | const spawnRows = entries.filter((entry) => entry.agentSpawn !== undefined); | ||
| 1910 | expect(spawnRows.map((row) => row.agentSpawn!.workflowId)).toEqual(["wf-run1", "wf-run2"]); | ||
| 1911 | expect(spawnRows.map((row) => row.turnId)).toEqual(["turn-1", "turn-2"]); | ||
| 1912 | }); | ||
| 1913 | }); |
apps/web/src/session-logic.ts+203-2| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import * as Option from "effect/Option"; | 1 | import * as Option from "effect/Option"; |
| 2 | import * as Arr from "effect/Array"; | 2 | import * as Arr from "effect/Array"; |
| 3 | import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; | ||
| 3 | import { | 4 | import { |
| 4 | ApprovalRequestId, | 5 | ApprovalRequestId, |
| 5 | isToolLifecycleItemType, | 6 | isToolLifecycleItemType, |
| ... | @@ -78,12 +79,30 @@ export interface WorkLogEntry { | ... | @@ -78,12 +79,30 @@ export interface WorkLogEntry { |
| 78 | toolLifecycleStatus?: WorkLogToolLifecycleStatus; | 79 | toolLifecycleStatus?: WorkLogToolLifecycleStatus; |
| 79 | /** Originating orchestration activity kind (e.g. `user-input.requested`) for row chrome. */ | 80 | /** Originating orchestration activity kind (e.g. `user-input.requested`) for row chrome. */ |
| 80 | sourceActivityKind?: OrchestrationThreadActivity["kind"]; | 81 | sourceActivityKind?: OrchestrationThreadActivity["kind"]; |
| 82 | /** Grouping key for subagent lifecycle rows (one row per agent). */ | ||
| 83 | taskId?: string; | ||
| 84 | /** Agent role (subagent_type) for labeled timeline rows. */ | ||
| 85 | agentRole?: string; | ||
| 86 | /** | ||
| 87 | * Present on agent-spawn CTA rows: one per workflow run or per-turn batch | ||
| 88 | * of direct spawns. The row renders as a call-to-action ("Kicked off N | ||
| 89 | * subagents") whose live status is derived from the agent panel model at | ||
| 90 | * render time; clicking opens the Agents panel. | ||
| 91 | */ | ||
| 92 | agentSpawn?: { | ||
| 93 | /** Workflow coordinator taskId, or null for a direct-spawn batch. */ | ||
| 94 | workflowId: string | null; | ||
| 95 | agentTaskIds: ReadonlyArray<string>; | ||
| 96 | }; | ||
| 81 | } | 97 | } |
| 82 | 98 | ||
| 83 | interface DerivedWorkLogEntry extends WorkLogEntry { | 99 | interface DerivedWorkLogEntry extends WorkLogEntry { |
| 84 | activityKind: OrchestrationThreadActivity["kind"]; | 100 | activityKind: OrchestrationThreadActivity["kind"]; |
| 85 | collapseKey?: string; | 101 | collapseKey?: string; |
| 86 | toolCallId?: string; | 102 | toolCallId?: string; |
| 103 | isWorkflowCoordinator?: boolean; | ||
| 104 | /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn CTAs. */ | ||
| 105 | isBackgroundTask?: boolean; | ||
| 87 | } | 106 | } |
| 88 | 107 | ||
| 89 | export interface PendingApproval { | 108 | export interface PendingApproval { |
| ... | @@ -250,6 +269,12 @@ export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { | ... | @@ -250,6 +269,12 @@ export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { |
| 250 | 269 | ||
| 251 | /** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */ | 270 | /** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */ |
| 252 | export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean { | 271 | export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean { |
| 272 | // Spawn CTA rows are never neutral-hidden: mid-run they derive from | ||
| 273 | // task.progress (tone "thinking") and the neutral filter was swallowing | ||
| 274 | // them exactly while the fleet ran — the one moment they matter most. | ||
| 275 | if (entry.agentSpawn !== undefined) { | ||
| 276 | return false; | ||
| 277 | } | ||
| 253 | if (!workLogEntryIsToolLike(entry)) { | 278 | if (!workLogEntryIsToolLike(entry)) { |
| 254 | return false; | 279 | return false; |
| 255 | } | 280 | } |
| ... | @@ -624,6 +649,67 @@ export function hasActionableProposedPlan( | ... | @@ -624,6 +649,67 @@ export function hasActionableProposedPlan( |
| 624 | return proposedPlan !== null && proposedPlan.implementedAt === null; | 649 | return proposedPlan !== null && proposedPlan.implementedAt === null; |
| 625 | } | 650 | } |
| 626 | 651 | ||
| 652 | /** | ||
| 653 | * Quiet-timeline guarantee: the work log carries the parent's narrative plus | ||
| 654 | * at most one row per agent. Everything an agent does internally lives in the | ||
| 655 | * Agents surface: | ||
| 656 | * - timelineBypass rows (Codex children, workflow members) never render here; | ||
| 657 | * - tool rows attributed to an owning agent (payload.agentId) are re-homed; | ||
| 658 | * - task.progress ticks collapse into one row per taskId; | ||
| 659 | * - task.updated is fold input only (status patches are not narrative). | ||
| 660 | * Unattributed rows always stay: over-hiding loses the only terminal signal. | ||
| 661 | */ | ||
| 662 | /** Agent (non-background) task.started rows seed spawn CTA batches. */ | ||
| 663 | function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { | ||
| 664 | const payload = | ||
| 665 | activity.payload && typeof activity.payload === "object" | ||
| 666 | ? (activity.payload as Record<string, unknown>) | ||
| 667 | : null; | ||
| 668 | if (!payload || typeof payload.taskId !== "string") { | ||
| 669 | return false; | ||
| 670 | } | ||
| 671 | return !isBackgroundTaskActivity(payload); | ||
| 672 | } | ||
| 673 | |||
| 674 | function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { | ||
| 675 | const payload = | ||
| 676 | activity.payload && typeof activity.payload === "object" | ||
| 677 | ? (activity.payload as Record<string, unknown>) | ||
| 678 | : null; | ||
| 679 | if (!payload) { | ||
| 680 | return false; | ||
| 681 | } | ||
| 682 | const isTaskRow = | ||
| 683 | activity.kind === "task.started" || | ||
| 684 | activity.kind === "task.progress" || | ||
| 685 | activity.kind === "task.updated" || | ||
| 686 | activity.kind === "task.completed"; | ||
| 687 | // Task rows classify by the server stamp: a subagent's own background | ||
| 688 | // shell (agentId + "background") is agent-internal, but a nested AGENT | ||
| 689 | // (agentId + "agent") stays visible so its rows can anchor a spawn CTA | ||
| 690 | // (review finding: hiding on agentId alone removed nested agents and | ||
| 691 | // their anchors). Bypassed agent lifecycle rows also pass — collapse | ||
| 692 | // folds every such row into its batch's single CTA row, which is how | ||
| 693 | // Codex children (whose rows are ALL bypassed) get an anchor at the | ||
| 694 | // spawn point. | ||
| 695 | if (isTaskRow) { | ||
| 696 | const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0; | ||
| 697 | if (ownedByAgent || payload.timelineBypass === true) { | ||
| 698 | const isAgentTaskRow = | ||
| 699 | activity.kind !== "task.updated" && | ||
| 700 | typeof payload.taskId === "string" && | ||
| 701 | !isBackgroundTaskActivity(payload); | ||
| 702 | return !isAgentTaskRow; | ||
| 703 | } | ||
| 704 | return false; | ||
| 705 | } | ||
| 706 | if (payload.timelineBypass === true) { | ||
| 707 | return true; | ||
| 708 | } | ||
| 709 | // Non-task rows (attributed tool activity) owned by an agent are internal. | ||
| 710 | return typeof payload.agentId === "string" && payload.agentId.trim().length > 0; | ||
| 711 | } | ||
| 712 | |||
| 627 | export function deriveWorkLogEntries( | 713 | export function deriveWorkLogEntries( |
| 628 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 714 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 629 | ): WorkLogEntry[] { | 715 | ): WorkLogEntry[] { |
| ... | @@ -631,10 +717,17 @@ export function deriveWorkLogEntries( | ... | @@ -631,10 +717,17 @@ export function deriveWorkLogEntries( |
| 631 | const entries: DerivedWorkLogEntry[] = []; | 717 | const entries: DerivedWorkLogEntry[] = []; |
| 632 | for (const activity of ordered) { | 718 | for (const activity of ordered) { |
| 633 | if (activity.kind === "tool.started") continue; | 719 | if (activity.kind === "tool.started") continue; |
| 634 | if (activity.kind === "task.started") continue; | 720 | // Agent task.started rows are CTA seeds: they carry the true spawn turn, |
| 721 | // which is the batch key (completions of background subagents arrive | ||
| 722 | // under later synthetic turns and must not start new batches). They | ||
| 723 | // collapse into the batch's single CTA row, never render standalone. | ||
| 724 | if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue; | ||
| 725 | if (activity.kind === "task.updated") continue; | ||
| 726 | if (activity.kind === "tool.progress") continue; | ||
| 635 | if (activity.kind === "context-window.updated") continue; | 727 | if (activity.kind === "context-window.updated") continue; |
| 636 | if (activity.summary === "Checkpoint captured") continue; | 728 | if (activity.summary === "Checkpoint captured") continue; |
| 637 | if (isPlanBoundaryToolActivity(activity)) continue; | 729 | if (isPlanBoundaryToolActivity(activity)) continue; |
| 730 | if (isAgentInternalActivity(activity)) continue; | ||
| 638 | entries.push(toDerivedWorkLogEntry(activity)); | 731 | entries.push(toDerivedWorkLogEntry(activity)); |
| 639 | } | 732 | } |
| 640 | return collapseDerivedWorkLogEntries(entries).map((entry) => { | 733 | return collapseDerivedWorkLogEntries(entries).map((entry) => { |
| ... | @@ -682,7 +775,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -682,7 +775,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 682 | const commandPreview = extractToolCommand(payload); | 775 | const commandPreview = extractToolCommand(payload); |
| 683 | const changedFiles = extractChangedFiles(payload); | 776 | const changedFiles = extractChangedFiles(payload); |
| 684 | const title = extractToolTitle(payload); | 777 | const title = extractToolTitle(payload); |
| 685 | const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; | 778 | const isTaskActivity = |
| 779 | activity.kind === "task.started" || | ||
| 780 | activity.kind === "task.progress" || | ||
| 781 | activity.kind === "task.completed"; | ||
| 686 | const taskSummary = | 782 | const taskSummary = |
| 687 | isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 | 783 | isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 |
| 688 | ? payload.summary | 784 | ? payload.summary |
| ... | @@ -756,6 +852,22 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -756,6 +852,22 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 756 | if (toolLifecycleStatus) { | 852 | if (toolLifecycleStatus) { |
| 757 | entry.toolLifecycleStatus = toolLifecycleStatus; | 853 | entry.toolLifecycleStatus = toolLifecycleStatus; |
| 758 | } | 854 | } |
| 855 | if (isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0) { | ||
| 856 | entry.taskId = payload.taskId; | ||
| 857 | } | ||
| 858 | if (isTaskActivity && typeof payload?.role === "string" && payload.role.length > 0) { | ||
| 859 | entry.agentRole = payload.role; | ||
| 860 | } | ||
| 861 | if ( | ||
| 862 | isTaskActivity && | ||
| 863 | (payload?.taskType === "local_workflow" || | ||
| 864 | (typeof payload?.workflowName === "string" && payload.workflowName.length > 0)) | ||
| 865 | ) { | ||
| 866 | entry.isWorkflowCoordinator = true; | ||
| 867 | } | ||
| 868 | if (isTaskActivity && payload && isBackgroundTaskActivity(payload)) { | ||
| 869 | entry.isBackgroundTask = true; | ||
| 870 | } | ||
| 759 | const collapseKey = deriveToolLifecycleCollapseKey(entry); | 871 | const collapseKey = deriveToolLifecycleCollapseKey(entry); |
| 760 | if (collapseKey) { | 872 | if (collapseKey) { |
| 761 | entry.collapseKey = collapseKey; | 873 | entry.collapseKey = collapseKey; |
| ... | @@ -763,11 +875,92 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -763,11 +875,92 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 763 | return entry; | 875 | return entry; |
| 764 | } | 876 | } |
| 765 | 877 | ||
| 878 | /** | ||
| 879 | * Spawn-group key for a subagent lifecycle row. Workflow members and their | ||
| 880 | * coordinator share the coordinator's group; direct spawns batch per turn. | ||
| 881 | * One CTA row per group (A1 design): "Kicked off N subagents". | ||
| 882 | */ | ||
| 883 | function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { | ||
| 884 | const taskId = entry.taskId ?? ""; | ||
| 885 | const workflowSlot = taskId.indexOf(":wf:"); | ||
| 886 | if (workflowSlot !== -1) { | ||
| 887 | return `wf:${taskId.slice(0, workflowSlot)}`; | ||
| 888 | } | ||
| 889 | if (entry.agentSpawn?.workflowId) { | ||
| 890 | return `wf:${entry.agentSpawn.workflowId}`; | ||
| 891 | } | ||
| 892 | if (entry.isWorkflowCoordinator) { | ||
| 893 | return `wf:${taskId}`; | ||
| 894 | } | ||
| 895 | // No turn id means no batch signal at all: fall back to one group per | ||
| 896 | // task. Unrelated turn-less spawns (separate fleets whose rows lost their | ||
| 897 | // turn) must not collapse into one immortal "direct:no-turn" CTA | ||
| 898 | // accumulating every agent the thread ever ran (review finding). Adapters | ||
| 899 | // stamp spawn turns (Codex spawnTurnId; Claude rows ride real turns), so | ||
| 900 | // this path is defensive. | ||
| 901 | return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; | ||
| 902 | } | ||
| 903 | |||
| 766 | function collapseDerivedWorkLogEntries( | 904 | function collapseDerivedWorkLogEntries( |
| 767 | entries: ReadonlyArray<DerivedWorkLogEntry>, | 905 | entries: ReadonlyArray<DerivedWorkLogEntry>, |
| 768 | ): DerivedWorkLogEntry[] { | 906 | ): DerivedWorkLogEntry[] { |
| 769 | const collapsed: DerivedWorkLogEntry[] = []; | 907 | const collapsed: DerivedWorkLogEntry[] = []; |
| 908 | // Subagent rows collapse by spawn group, not adjacency: a workflow run (or | ||
| 909 | // a turn's batch of direct spawns) is ONE narrative event in the chat — a | ||
| 910 | // CTA row that opens the Agents panel — no matter how many agents it | ||
| 911 | // contains or how their progress rows interleave (quiet-timeline | ||
| 912 | // guarantee). | ||
| 913 | const spawnRowIndex = new Map<string, number>(); | ||
| 914 | // Batch membership is decided once, at the FIRST row seen for a taskId. | ||
| 915 | // Claude background subagents settle between turns, so their completion | ||
| 916 | // rows carry fresh synthetic turn ids (or none) — keying each row by its | ||
| 917 | // own turn splintered one batch into a stream of "Kicked off N subagents" | ||
| 918 | // rows (live-test finding, thread 7ac7ef05). | ||
| 919 | const groupKeyByTaskId = new Map<string, string>(); | ||
| 770 | for (const entry of entries) { | 920 | for (const entry of entries) { |
| 921 | const isTaskRow = | ||
| 922 | entry.taskId !== undefined && | ||
| 923 | !entry.isBackgroundTask && | ||
| 924 | (entry.activityKind === "task.started" || | ||
| 925 | entry.activityKind === "task.progress" || | ||
| 926 | entry.activityKind === "task.completed"); | ||
| 927 | if (isTaskRow && entry.taskId !== undefined) { | ||
| 928 | const rememberedKey = groupKeyByTaskId.get(entry.taskId); | ||
| 929 | const groupKey = rememberedKey ?? agentSpawnGroupKey(entry); | ||
| 930 | if (rememberedKey === undefined) { | ||
| 931 | groupKeyByTaskId.set(entry.taskId, groupKey); | ||
| 932 | } | ||
| 933 | const workflowId = groupKey.startsWith("wf:") ? groupKey.slice(3) : null; | ||
| 934 | const existingIndex = spawnRowIndex.get(groupKey); | ||
| 935 | if (existingIndex !== undefined) { | ||
| 936 | const existing = collapsed[existingIndex]!; | ||
| 937 | const agentTaskIds = existing.agentSpawn?.agentTaskIds.includes(entry.taskId) | ||
| 938 | ? existing.agentSpawn.agentTaskIds | ||
| 939 | : [...(existing.agentSpawn?.agentTaskIds ?? []), entry.taskId]; | ||
| 940 | collapsed[existingIndex] = { | ||
| 941 | ...mergeDerivedWorkLogEntries(existing, entry), | ||
| 942 | // The CTA row keeps the group's ANCHOR identity, not the last | ||
| 943 | // agent's: id/createdAt/turnId stay pinned to the spawn point so | ||
| 944 | // the row renders where the run launched instead of drifting to | ||
| 945 | // the newest progress tick (mid-run it drifted below the whole | ||
| 946 | // conversation, reading as "no visualization"), and the stable id | ||
| 947 | // keeps React state/virtualization sane. | ||
| 948 | id: existing.id, | ||
| 949 | createdAt: existing.createdAt, | ||
| 950 | turnId: existing.turnId ?? null, | ||
| 951 | ...(existing.taskId !== undefined ? { taskId: existing.taskId } : {}), | ||
| 952 | label: existing.label, | ||
| 953 | agentSpawn: { workflowId, agentTaskIds }, | ||
| 954 | }; | ||
| 955 | continue; | ||
| 956 | } | ||
| 957 | spawnRowIndex.set(groupKey, collapsed.length); | ||
| 958 | collapsed.push({ | ||
| 959 | ...entry, | ||
| 960 | agentSpawn: { workflowId, agentTaskIds: [entry.taskId] }, | ||
| 961 | }); | ||
| 962 | continue; | ||
| 963 | } | ||
| 771 | const previous = collapsed.at(-1); | 964 | const previous = collapsed.at(-1); |
| 772 | if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { | 965 | if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { |
| 773 | collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); | 966 | collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); |
| ... | @@ -847,6 +1040,14 @@ function mergeChangedFiles( | ... | @@ -847,6 +1040,14 @@ function mergeChangedFiles( |
| 847 | } | 1040 | } |
| 848 | 1041 | ||
| 849 | function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { | 1042 | function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { |
| 1043 | // Subagent lifecycle rows collapse by agent identity: one row per agent, | ||
| 1044 | // progress ticks fold into it, the terminal row wins the label. | ||
| 1045 | if ( | ||
| 1046 | entry.taskId && | ||
| 1047 | (entry.activityKind === "task.progress" || entry.activityKind === "task.completed") | ||
| 1048 | ) { | ||
| 1049 | return `task${entry.taskId}`; | ||
| 1050 | } | ||
| 850 | if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { | 1051 | if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { |
| 851 | return undefined; | 1052 | return undefined; |
| 852 | } | 1053 | } |
packages/client-runtime/package.json+4| ... | @@ -123,6 +123,10 @@ | ... | @@ -123,6 +123,10 @@ |
| 123 | "types": "./src/state/threads.ts", | 123 | "types": "./src/state/threads.ts", |
| 124 | "default": "./src/state/threads.ts" | 124 | "default": "./src/state/threads.ts" |
| 125 | }, | 125 | }, |
| 126 | "./state/subagentRuntime": { | ||
| 127 | "types": "./src/state/subagentRuntime.ts", | ||
| 128 | "default": "./src/state/subagentRuntime.ts" | ||
| 129 | }, | ||
| 126 | "./state/thread-sort": { | 130 | "./state/thread-sort": { |
| 127 | "types": "./src/state/threadSort.ts", | 131 | "types": "./src/state/threadSort.ts", |
| 128 | "default": "./src/state/threadSort.ts" | 132 | "default": "./src/state/threadSort.ts" |
packages/client-runtime/src/state/orchestration.ts+7| ... | @@ -12,6 +12,13 @@ export function createOrchestrationEnvironmentAtoms<R, E>( | ... | @@ -12,6 +12,13 @@ export function createOrchestrationEnvironmentAtoms<R, E>( |
| 12 | label: "environment-data:orchestration:turn-diff", | 12 | label: "environment-data:orchestration:turn-diff", |
| 13 | tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | 13 | tag: ORCHESTRATION_WS_METHODS.getTurnDiff, |
| 14 | }), | 14 | }), |
| 15 | workflowScript: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 16 | label: "environment-data:orchestration:workflow-script", | ||
| 17 | tag: ORCHESTRATION_WS_METHODS.getWorkflowScript, | ||
| 18 | // Scripts are immutable per run: cache generously. | ||
| 19 | staleTimeMs: 300_000, | ||
| 20 | idleTtlMs: 300_000, | ||
| 21 | }), | ||
| 15 | fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { | 22 | fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 16 | label: "environment-data:orchestration:full-thread-diff", | 23 | label: "environment-data:orchestration:full-thread-diff", |
| 17 | tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, | 24 | tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, |
packages/client-runtime/src/state/subagentRuntime.test.ts created+772| ... | @@ -0,0 +1,772 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { classifyTaskAgentKind, type OrchestrationThreadActivity } from "@t3tools/contracts"; | ||
| 3 | import { | ||
| 4 | deriveAgentPanelModel, | ||
| 5 | foldSubagentActivities, | ||
| 6 | formatSubagentModelLabel, | ||
| 7 | formatSubagentTokenCount, | ||
| 8 | isAgentAttributedToolActivity, | ||
| 9 | isSubagentActivityKind, | ||
| 10 | isTimelineBypassActivity, | ||
| 11 | workflowCardMembers, | ||
| 12 | } from "./subagentRuntime.ts"; | ||
| 13 | |||
| 14 | let sequence = 0; | ||
| 15 | /** | ||
| 16 | * Fixtures model POST-INGESTION rows: ingestion stamps agentKind on every | ||
| 17 | * task.* payload, so the helper stamps too (same classifier). Pass an | ||
| 18 | * explicit agentKind (or agentKind: undefined via legacy()) to override. | ||
| 19 | */ | ||
| 20 | function activity( | ||
| 21 | kind: string, | ||
| 22 | payload: Record<string, unknown>, | ||
| 23 | at = `2026-08-01T10:00:${String(sequence).padStart(2, "0")}.000Z`, | ||
| 24 | ): OrchestrationThreadActivity { | ||
| 25 | sequence += 1; | ||
| 26 | const stamped = | ||
| 27 | kind.startsWith("task.") && !("agentKind" in payload) | ||
| 28 | ? { | ||
| 29 | ...payload, | ||
| 30 | agentKind: classifyTaskAgentKind({ | ||
| 31 | taskType: typeof payload.taskType === "string" ? payload.taskType : undefined, | ||
| 32 | agentId: typeof payload.agentId === "string" ? payload.agentId : undefined, | ||
| 33 | }), | ||
| 34 | } | ||
| 35 | : payload; | ||
| 36 | return { | ||
| 37 | id: `activity-${sequence}`, | ||
| 38 | tone: "info", | ||
| 39 | kind, | ||
| 40 | summary: kind, | ||
| 41 | payload: stamped, | ||
| 42 | turnId: null, | ||
| 43 | createdAt: at, | ||
| 44 | } as unknown as OrchestrationThreadActivity; | ||
| 45 | } | ||
| 46 | |||
| 47 | /** A pre-stamp row (legacy thread / old server): no agentKind at all. */ | ||
| 48 | function legacyActivity( | ||
| 49 | kind: string, | ||
| 50 | payload: Record<string, unknown>, | ||
| 51 | ): OrchestrationThreadActivity { | ||
| 52 | sequence += 1; | ||
| 53 | return { | ||
| 54 | id: `activity-${sequence}`, | ||
| 55 | tone: "info", | ||
| 56 | kind, | ||
| 57 | summary: kind, | ||
| 58 | payload, | ||
| 59 | turnId: null, | ||
| 60 | createdAt: `2026-08-01T10:00:${String(sequence).padStart(2, "0")}.000Z`, | ||
| 61 | } as unknown as OrchestrationThreadActivity; | ||
| 62 | } | ||
| 63 | |||
| 64 | function fold(rows: ReadonlyArray<OrchestrationThreadActivity>) { | ||
| 65 | return foldSubagentActivities(rows); | ||
| 66 | } | ||
| 67 | |||
| 68 | describe("foldSubagentActivities", () => { | ||
| 69 | it("builds an agent from start → progress → completion", () => { | ||
| 70 | const agents = fold([ | ||
| 71 | activity("task.started", { | ||
| 72 | taskId: "task-1", | ||
| 73 | title: "Audit auth flow", | ||
| 74 | role: "explorer", | ||
| 75 | }), | ||
| 76 | activity("task.progress", { | ||
| 77 | taskId: "task-1", | ||
| 78 | lastToolName: "Read", | ||
| 79 | typedUsage: { totalTokens: 1200, toolUses: 3 }, | ||
| 80 | }), | ||
| 81 | activity("task.completed", { | ||
| 82 | taskId: "task-1", | ||
| 83 | status: "completed", | ||
| 84 | summary: "Found 2 issues", | ||
| 85 | typedUsage: { totalTokens: 5000, toolUses: 9 }, | ||
| 86 | }), | ||
| 87 | ]); | ||
| 88 | expect(agents).toHaveLength(1); | ||
| 89 | const agent = agents[0]!; | ||
| 90 | expect(agent.title).toBe("Audit auth flow"); | ||
| 91 | expect(agent.role).toBe("explorer"); | ||
| 92 | expect(agent.status).toBe("completed"); | ||
| 93 | expect(agent.result).toBe("Found 2 issues"); | ||
| 94 | expect(agent.usage?.totalTokens).toBe(5000); | ||
| 95 | expect(agent.activationCount).toBe(1); | ||
| 96 | expect(agent.completedAt).not.toBeNull(); | ||
| 97 | }); | ||
| 98 | |||
| 99 | it("progress can create an agent when its start row aged out of retention", () => { | ||
| 100 | const agents = fold([ | ||
| 101 | activity("task.progress", { | ||
| 102 | taskId: "task-orphan", | ||
| 103 | title: "Recovered agent", | ||
| 104 | role: "verifier", | ||
| 105 | typedUsage: { totalTokens: 100 }, | ||
| 106 | }), | ||
| 107 | ]); | ||
| 108 | expect(agents).toHaveLength(1); | ||
| 109 | expect(agents[0]!.title).toBe("Recovered agent"); | ||
| 110 | expect(agents[0]!.status).toBe("running"); | ||
| 111 | }); | ||
| 112 | |||
| 113 | it("completion before start stays terminal; a late start only fills metadata", () => { | ||
| 114 | const agents = fold([ | ||
| 115 | activity("task.completed", { | ||
| 116 | taskId: "task-2", | ||
| 117 | status: "failed", | ||
| 118 | summary: "boom", | ||
| 119 | role: "fixer", | ||
| 120 | }), | ||
| 121 | activity("task.started", { taskId: "task-2", title: "Late metadata", role: "fixer" }), | ||
| 122 | ]); | ||
| 123 | expect(agents).toHaveLength(1); | ||
| 124 | const agent = agents[0]!; | ||
| 125 | expect(agent.title).toBe("Late metadata"); | ||
| 126 | expect(agent.role).toBe("fixer"); | ||
| 127 | // The late start must NOT reopen the terminal activation as a new run. | ||
| 128 | expect(agent.status).toBe("failed"); | ||
| 129 | expect(agent.error).toBe("boom"); | ||
| 130 | }); | ||
| 131 | |||
| 132 | it("duplicate terminal events are idempotent (timestamps do not slide)", () => { | ||
| 133 | const agents = fold([ | ||
| 134 | activity("task.started", { taskId: "task-3", taskType: "local_agent" }), | ||
| 135 | activity( | ||
| 136 | "task.completed", | ||
| 137 | { taskId: "task-3", status: "completed" }, | ||
| 138 | "2026-08-01T11:00:00.000Z", | ||
| 139 | ), | ||
| 140 | activity( | ||
| 141 | "task.completed", | ||
| 142 | { taskId: "task-3", status: "completed" }, | ||
| 143 | "2026-08-01T12:00:00.000Z", | ||
| 144 | ), | ||
| 145 | ]); | ||
| 146 | expect(agents[0]!.completedAt).toBe("2026-08-01T11:00:00.000Z"); | ||
| 147 | }); | ||
| 148 | |||
| 149 | it("reactivation increments the run count and clears result/error", () => { | ||
| 150 | const agents = fold([ | ||
| 151 | activity("task.started", { taskId: "task-4", taskType: "local_agent" }), | ||
| 152 | activity("task.completed", { taskId: "task-4", status: "completed", summary: "run 1 done" }), | ||
| 153 | activity("task.updated", { taskId: "task-4", status: "running" }), | ||
| 154 | ]); | ||
| 155 | const agent = agents[0]!; | ||
| 156 | expect(agent.activationCount).toBe(2); | ||
| 157 | expect(agent.result).toBeNull(); | ||
| 158 | expect(agent.completedAt).toBeNull(); | ||
| 159 | expect(agent.status).toBe("running"); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("idle is nonterminal: an idle agent resumes without losing identity", () => { | ||
| 163 | const agents = fold([ | ||
| 164 | activity("task.started", { taskId: "codex-child-1", title: "Marlow", role: "explorer" }), | ||
| 165 | activity("task.updated", { taskId: "codex-child-1", status: "idle" }), | ||
| 166 | activity("task.updated", { taskId: "codex-child-1", status: "running" }), | ||
| 167 | ]); | ||
| 168 | expect(agents).toHaveLength(1); | ||
| 169 | expect(agents[0]!.activationCount).toBe(2); | ||
| 170 | expect(agents[0]!.status).toBe("running"); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it("cumulative usage max-merges: duplicate and late frames never shrink or double-count", () => { | ||
| 174 | const agents = fold([ | ||
| 175 | activity("task.started", { taskId: "task-5", taskType: "local_agent" }), | ||
| 176 | activity("task.progress", { | ||
| 177 | taskId: "task-5", | ||
| 178 | typedUsage: { totalTokens: 900, inputTokens: 700 }, | ||
| 179 | }), | ||
| 180 | activity("task.progress", { | ||
| 181 | taskId: "task-5", | ||
| 182 | typedUsage: { totalTokens: 900, inputTokens: 700 }, | ||
| 183 | }), | ||
| 184 | activity("task.progress", { taskId: "task-5", typedUsage: { totalTokens: 500 } }), | ||
| 185 | ]); | ||
| 186 | expect(agents[0]!.usage).toEqual({ totalTokens: 900, inputTokens: 700 }); | ||
| 187 | }); | ||
| 188 | |||
| 189 | it("partial terminal usage preserves known breakdown fields", () => { | ||
| 190 | const agents = fold([ | ||
| 191 | activity("task.started", { taskId: "task-6", taskType: "local_agent" }), | ||
| 192 | activity("task.progress", { | ||
| 193 | taskId: "task-6", | ||
| 194 | typedUsage: { totalTokens: 800, inputTokens: 600, outputTokens: 150 }, | ||
| 195 | }), | ||
| 196 | activity("task.completed", { | ||
| 197 | taskId: "task-6", | ||
| 198 | status: "completed", | ||
| 199 | typedUsage: { totalTokens: 1000 }, | ||
| 200 | }), | ||
| 201 | ]); | ||
| 202 | expect(agents[0]!.usage).toEqual({ totalTokens: 1000, inputTokens: 600, outputTokens: 150 }); | ||
| 203 | }); | ||
| 204 | |||
| 205 | it("skips malformed rows individually without failing the fold", () => { | ||
| 206 | const agents = fold([ | ||
| 207 | activity("task.started", { taskId: "task-7", title: "Good", taskType: "local_agent" }), | ||
| 208 | activity("task.progress", { bogus: true }), | ||
| 209 | activity("task.progress", { taskId: 42 }), | ||
| 210 | ]); | ||
| 211 | expect(agents).toHaveLength(1); | ||
| 212 | expect(agents[0]!.title).toBe("Good"); | ||
| 213 | }); | ||
| 214 | |||
| 215 | it("bounds repeated strings at 180 chars and the activity ring at 6 deduped entries", () => { | ||
| 216 | const long = "x".repeat(500); | ||
| 217 | const rows = [activity("task.started", { taskId: "task-8", taskType: "local_agent" })]; | ||
| 218 | for (let i = 0; i < 10; i += 1) { | ||
| 219 | rows.push(activity("task.progress", { taskId: "task-8", summary: `${long}-${i}` })); | ||
| 220 | } | ||
| 221 | rows.push(activity("task.progress", { taskId: "task-8", summary: `${long}-9` })); | ||
| 222 | const agents = fold(rows); | ||
| 223 | const agent = agents[0]!; | ||
| 224 | expect(agent.recentActivity.length).toBeLessThanOrEqual(6); | ||
| 225 | for (const entry of agent.recentActivity) { | ||
| 226 | expect(entry.summary.length).toBeLessThanOrEqual(180); | ||
| 227 | } | ||
| 228 | // Consecutive identical summaries dedupe (truncation makes them equal). | ||
| 229 | const summaries = agent.recentActivity.map((entry) => entry.summary); | ||
| 230 | expect(new Set(summaries).size).toBe(summaries.length); | ||
| 231 | }); | ||
| 232 | |||
| 233 | it("plan tasks are not agents", () => { | ||
| 234 | const agents = fold([activity("task.started", { taskId: "plan-1", taskType: "plan" })]); | ||
| 235 | expect(agents).toHaveLength(0); | ||
| 236 | }); | ||
| 237 | |||
| 238 | it("workflow members key by stable slot and attach to their coordinator", () => { | ||
| 239 | const agents = fold([ | ||
| 240 | activity("task.started", { | ||
| 241 | taskId: "wf-1", | ||
| 242 | taskType: "local_workflow", | ||
| 243 | title: "audit-auth-flow", | ||
| 244 | workflowName: "audit-auth-flow", | ||
| 245 | }), | ||
| 246 | activity("task.progress", { | ||
| 247 | taskId: "wf-1", | ||
| 248 | phases: [ | ||
| 249 | { index: 0, title: "Audit" }, | ||
| 250 | { index: 1, title: "Verify" }, | ||
| 251 | ], | ||
| 252 | }), | ||
| 253 | activity("task.progress", { | ||
| 254 | taskId: "wf-1:wf:0", | ||
| 255 | title: "audit:entrypoints", | ||
| 256 | status: "running", | ||
| 257 | parentAgentId: "wf-1", | ||
| 258 | agentIndex: 0, | ||
| 259 | phaseIndex: 0, | ||
| 260 | phaseTitle: "Audit", | ||
| 261 | timelineBypass: true, | ||
| 262 | }), | ||
| 263 | ]); | ||
| 264 | const workflow = agents.find((agent) => agent.id === "wf-1"); | ||
| 265 | const member = agents.find((agent) => agent.id === "wf-1:wf:0"); | ||
| 266 | expect(workflow?.kind).toBe("workflow"); | ||
| 267 | expect(workflow?.phases).toEqual([ | ||
| 268 | { index: 0, title: "Audit" }, | ||
| 269 | { index: 1, title: "Verify" }, | ||
| 270 | ]); | ||
| 271 | expect(member?.kind).toBe("workflow_agent"); | ||
| 272 | expect(member?.parentAgentId).toBe("wf-1"); | ||
| 273 | }); | ||
| 274 | |||
| 275 | it("a workflow member retry (attempt bump) is a reactivation of the same slot", () => { | ||
| 276 | const agents = fold([ | ||
| 277 | activity("task.progress", { | ||
| 278 | taskId: "wf-2:wf:1", | ||
| 279 | title: "verify:refresh", | ||
| 280 | status: "failed", | ||
| 281 | error: "attempt 1 died", | ||
| 282 | parentAgentId: "wf-2", | ||
| 283 | attempt: 1, | ||
| 284 | }), | ||
| 285 | activity("task.progress", { | ||
| 286 | taskId: "wf-2:wf:1", | ||
| 287 | title: "verify:refresh", | ||
| 288 | status: "running", | ||
| 289 | parentAgentId: "wf-2", | ||
| 290 | attempt: 2, | ||
| 291 | }), | ||
| 292 | ]); | ||
| 293 | expect(agents).toHaveLength(1); | ||
| 294 | const member = agents[0]!; | ||
| 295 | expect(member.activationCount).toBeGreaterThanOrEqual(2); | ||
| 296 | expect(member.error).toBeNull(); | ||
| 297 | expect(member.status).toBe("running"); | ||
| 298 | }); | ||
| 299 | |||
| 300 | it("drops non-http(s) session urls at the fold boundary", () => { | ||
| 301 | const agents = fold([ | ||
| 302 | activity("task.started", { | ||
| 303 | taskId: "wf-3", | ||
| 304 | taskType: "local_workflow", | ||
| 305 | runHandles: { sessionUrl: "javascript:alert(1)", runId: "run-1" }, | ||
| 306 | }), | ||
| 307 | ]); | ||
| 308 | expect(agents[0]!.runHandles?.sessionUrl).toBeUndefined(); | ||
| 309 | expect(agents[0]!.runHandles?.runId).toBe("run-1"); | ||
| 310 | }); | ||
| 311 | }); | ||
| 312 | |||
| 313 | describe("deriveAgentPanelModel", () => { | ||
| 314 | const roster = fold([ | ||
| 315 | activity("task.started", { taskId: "wf-1", taskType: "local_workflow", title: "audit" }), | ||
| 316 | activity("task.progress", { | ||
| 317 | taskId: "wf-1", | ||
| 318 | phases: [ | ||
| 319 | { index: 0, title: "Audit" }, | ||
| 320 | { index: 1, title: "Verify" }, | ||
| 321 | ], | ||
| 322 | }), | ||
| 323 | activity("task.progress", { | ||
| 324 | taskId: "wf-1:wf:0", | ||
| 325 | title: "audit:a", | ||
| 326 | status: "completed", | ||
| 327 | parentAgentId: "wf-1", | ||
| 328 | agentIndex: 0, | ||
| 329 | phaseIndex: 0, | ||
| 330 | }), | ||
| 331 | activity("task.completed", { taskId: "wf-1:wf:0", status: "completed", parentAgentId: "wf-1" }), | ||
| 332 | activity("task.progress", { | ||
| 333 | taskId: "wf-1:wf:1", | ||
| 334 | title: "verify:b", | ||
| 335 | status: "running", | ||
| 336 | parentAgentId: "wf-1", | ||
| 337 | agentIndex: 1, | ||
| 338 | phaseIndex: 1, | ||
| 339 | typedUsage: { totalTokens: 4000 }, | ||
| 340 | }), | ||
| 341 | activity("task.started", { taskId: "direct-1", title: "Marlow", role: "explorer" }), | ||
| 342 | activity("task.updated", { taskId: "direct-1", status: "idle" }), | ||
| 343 | ]); | ||
| 344 | |||
| 345 | it("groups workflow members by phase and separates direct spawns", () => { | ||
| 346 | const model = deriveAgentPanelModel({ agents: roster }); | ||
| 347 | expect(model.workflows).toHaveLength(1); | ||
| 348 | const group = model.workflows[0]!; | ||
| 349 | expect(group.phases).toHaveLength(2); | ||
| 350 | expect(group.phases[0]!.state).toBe("done"); | ||
| 351 | expect(group.phases[1]!.state).toBe("running"); | ||
| 352 | expect(model.directAgents.map((agent) => agent.id)).toEqual(["direct-1"]); | ||
| 353 | }); | ||
| 354 | |||
| 355 | it("counts idle deliberately and waiting as active", () => { | ||
| 356 | const model = deriveAgentPanelModel({ agents: roster }); | ||
| 357 | expect(model.idleCount).toBe(1); | ||
| 358 | // wf-1 coordinator + member 1 running. | ||
| 359 | expect(model.runningCount).toBeGreaterThanOrEqual(1); | ||
| 360 | expect(model.idleCount + model.runningCount + model.waitingCount + model.settledCount).toBe( | ||
| 361 | roster.length, | ||
| 362 | ); | ||
| 363 | }); | ||
| 364 | |||
| 365 | it("a phase with only pending members never reads as running", () => { | ||
| 366 | const pendingRoster = fold([ | ||
| 367 | activity("task.started", { taskId: "wf-9", taskType: "local_workflow" }), | ||
| 368 | activity("task.progress", { | ||
| 369 | taskId: "wf-9", | ||
| 370 | phases: [{ index: 0, title: "Fix" }], | ||
| 371 | }), | ||
| 372 | activity("task.progress", { | ||
| 373 | taskId: "wf-9:wf:0", | ||
| 374 | title: "fixer", | ||
| 375 | status: "pending", | ||
| 376 | parentAgentId: "wf-9", | ||
| 377 | agentIndex: 0, | ||
| 378 | phaseIndex: 0, | ||
| 379 | }), | ||
| 380 | ]); | ||
| 381 | const model = deriveAgentPanelModel({ agents: pendingRoster }); | ||
| 382 | // "pending" counts as active liveness (queued work), so the phase reads | ||
| 383 | // running only if a member is genuinely pending/running — this asserts | ||
| 384 | // the settled-count rule: no member settled, phase not done. | ||
| 385 | expect(model.workflows[0]!.phases[0]!.state).not.toBe("done"); | ||
| 386 | }); | ||
| 387 | |||
| 388 | it("v2 projection wins outright and sources are never merged", () => { | ||
| 389 | const v2Agent = { ...roster[0]!, id: "v2-only", title: "From v2" }; | ||
| 390 | const model = deriveAgentPanelModel({ agents: roster, v2Projection: [v2Agent] }); | ||
| 391 | const allIds = [ | ||
| 392 | ...model.workflows.map((group) => group.workflow.id), | ||
| 393 | ...model.directAgents.map((agent) => agent.id), | ||
| 394 | ]; | ||
| 395 | expect(allIds).toContain("v2-only"); | ||
| 396 | expect(allIds).not.toContain("direct-1"); | ||
| 397 | }); | ||
| 398 | |||
| 399 | it("orphaned members fall back to the direct list", () => { | ||
| 400 | const orphans = fold([ | ||
| 401 | activity("task.progress", { | ||
| 402 | taskId: "gone:wf:0", | ||
| 403 | title: "orphan", | ||
| 404 | status: "running", | ||
| 405 | parentAgentId: "gone", | ||
| 406 | }), | ||
| 407 | ]); | ||
| 408 | const model = deriveAgentPanelModel({ agents: orphans }); | ||
| 409 | expect(model.workflows).toHaveLength(0); | ||
| 410 | expect(model.directAgents.map((agent) => agent.id)).toEqual(["gone:wf:0"]); | ||
| 411 | }); | ||
| 412 | }); | ||
| 413 | |||
| 414 | describe("workflowCardMembers", () => { | ||
| 415 | it("orders by urgency (failed, running, waiting) and reports overflow", () => { | ||
| 416 | const roster = fold([ | ||
| 417 | activity("task.started", { taskId: "wf-1", taskType: "local_workflow" }), | ||
| 418 | ...[..."abcdefghij"].map((letter, index) => | ||
| 419 | activity("task.progress", { | ||
| 420 | taskId: `wf-1:wf:${index}`, | ||
| 421 | title: `agent-${letter}`, | ||
| 422 | status: index === 3 ? "failed" : index < 3 ? "completed" : "running", | ||
| 423 | ...(index === 3 ? { error: "died" } : {}), | ||
| 424 | parentAgentId: "wf-1", | ||
| 425 | agentIndex: index, | ||
| 426 | phaseIndex: 0, | ||
| 427 | phaseTitle: "Work", | ||
| 428 | }), | ||
| 429 | ), | ||
| 430 | ]); | ||
| 431 | const model = deriveAgentPanelModel({ agents: roster }); | ||
| 432 | const { visible, overflow } = workflowCardMembers(model.workflows[0]!, 8); | ||
| 433 | expect(visible).toHaveLength(8); | ||
| 434 | expect(overflow).toBe(2); | ||
| 435 | expect(visible[0]!.status).toBe("failed"); | ||
| 436 | expect(visible.filter((agent) => agent.status === "completed").length).toBeLessThanOrEqual(2); | ||
| 437 | }); | ||
| 438 | }); | ||
| 439 | |||
| 440 | describe("timeline predicates", () => { | ||
| 441 | it("recognizes subagent activity kinds as fold input", () => { | ||
| 442 | for (const kind of [ | ||
| 443 | "task.started", | ||
| 444 | "task.progress", | ||
| 445 | "task.updated", | ||
| 446 | "task.completed", | ||
| 447 | "tool.progress", | ||
| 448 | ]) { | ||
| 449 | expect(isSubagentActivityKind(kind)).toBe(true); | ||
| 450 | } | ||
| 451 | expect(isSubagentActivityKind("tool.completed")).toBe(false); | ||
| 452 | }); | ||
| 453 | |||
| 454 | it("attributed tool rows are re-homed; unattributed rows stay in the timeline", () => { | ||
| 455 | expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: "task-1" }))).toBe( | ||
| 456 | true, | ||
| 457 | ); | ||
| 458 | expect(isAgentAttributedToolActivity(activity("tool.completed", {}))).toBe(false); | ||
| 459 | expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: " " }))).toBe( | ||
| 460 | false, | ||
| 461 | ); | ||
| 462 | }); | ||
| 463 | |||
| 464 | it("timelineBypass rows never render in the parent chat", () => { | ||
| 465 | expect(isTimelineBypassActivity(activity("task.progress", { timelineBypass: true }))).toBe( | ||
| 466 | true, | ||
| 467 | ); | ||
| 468 | expect(isTimelineBypassActivity(activity("task.progress", {}))).toBe(false); | ||
| 469 | }); | ||
| 470 | }); | ||
| 471 | |||
| 472 | describe("formatSubagentTokenCount", () => { | ||
| 473 | it("formats plain counters", () => { | ||
| 474 | expect(formatSubagentTokenCount(950)).toBe("950"); | ||
| 475 | expect(formatSubagentTokenCount(41200)).toBe("41.2k"); | ||
| 476 | expect(formatSubagentTokenCount(247000)).toBe("247k"); | ||
| 477 | expect(formatSubagentTokenCount(1_400_000)).toBe("1.4M"); | ||
| 478 | }); | ||
| 479 | }); | ||
| 480 | |||
| 481 | describe("model and effort attribution", () => { | ||
| 482 | it("carries model/effort from start rows and refines model from later rows", () => { | ||
| 483 | const agents = fold([ | ||
| 484 | activity("task.started", { | ||
| 485 | taskId: "task-m", | ||
| 486 | title: "Verify math", | ||
| 487 | model: "sonnet", | ||
| 488 | effort: "high", | ||
| 489 | }), | ||
| 490 | // Later row refines with the authoritative API model id; effort absent | ||
| 491 | // must not clear the known value. | ||
| 492 | activity("task.progress", { taskId: "task-m", model: "claude-sonnet-5[1m]" }), | ||
| 493 | ]); | ||
| 494 | expect(agents).toHaveLength(1); | ||
| 495 | expect(agents[0]!.model).toBe("claude-sonnet-5[1m]"); | ||
| 496 | expect(agents[0]!.effort).toBe("high"); | ||
| 497 | }); | ||
| 498 | |||
| 499 | it("formatSubagentModelLabel compacts ids and appends effort", () => { | ||
| 500 | expect(formatSubagentModelLabel("claude-sonnet-5[1m]", "high")).toBe("sonnet-5[1m] · high"); | ||
| 501 | expect(formatSubagentModelLabel("claude-opus-4-20250514", null)).toBe("opus-4"); | ||
| 502 | expect(formatSubagentModelLabel("gpt-5.6-sol", "low")).toBe("gpt-5.6-sol · low"); | ||
| 503 | expect(formatSubagentModelLabel(null, "high")).toBeNull(); | ||
| 504 | }); | ||
| 505 | }); | ||
| 506 | |||
| 507 | describe("background task exclusion", () => { | ||
| 508 | it("shells and monitors never join the roster (from any lifecycle row)", () => { | ||
| 509 | const agents = fold([ | ||
| 510 | activity("task.started", { taskId: "shell-1", taskType: "shell", title: "Run 12s stall" }), | ||
| 511 | activity("task.progress", { taskId: "shell-2", taskType: "shell", title: "Run stall" }), | ||
| 512 | activity("task.completed", { taskId: "mon-1", taskType: "monitor", status: "completed" }), | ||
| 513 | activity("task.started", { taskId: "agent-1", taskType: "subagent", title: "Real agent" }), | ||
| 514 | ]); | ||
| 515 | expect(agents.map((agent) => agent.id)).toEqual(["agent-1"]); | ||
| 516 | }); | ||
| 517 | |||
| 518 | it("rows without a taskType stay in the roster (workflow members, Codex children)", () => { | ||
| 519 | const agents = fold([ | ||
| 520 | activity("task.progress", { taskId: "wf-1:wf:0", status: "running", parentAgentId: "wf-1" }), | ||
| 521 | ]); | ||
| 522 | expect(agents).toHaveLength(1); | ||
| 523 | }); | ||
| 524 | |||
| 525 | it("the server stamp is the only classifier: no stamp means no roster row", () => { | ||
| 526 | const agents = fold([ | ||
| 527 | // Stamped background: agent-looking fields don't matter. | ||
| 528 | activity("task.started", { | ||
| 529 | taskId: "bg-1", | ||
| 530 | agentKind: "background", | ||
| 531 | role: "watcher", | ||
| 532 | model: "sonnet", | ||
| 533 | }), | ||
| 534 | // Stamped agent: plain row still joins the roster. | ||
| 535 | activity("task.started", { taskId: "ag-1", agentKind: "agent", detail: "plain row" }), | ||
| 536 | // Legacy pre-stamp rows (old threads/servers) stay in the work log — | ||
| 537 | // exactly their pre-upgrade behavior. | ||
| 538 | legacyActivity("task.started", { taskId: "old-task", detail: "tailing logs" }), | ||
| 539 | legacyActivity("task.progress", { taskId: "old-task", summary: "still tailing" }), | ||
| 540 | ]); | ||
| 541 | expect(agents.map((agent) => agent.id)).toEqual(["ag-1"]); | ||
| 542 | }); | ||
| 543 | |||
| 544 | it("membership is sticky: a stampless later row still reaches a known agent", () => { | ||
| 545 | const agents = fold([ | ||
| 546 | activity("task.started", { taskId: "a1", taskType: "local_agent", title: "Agent" }), | ||
| 547 | // Terminal row missing the stamp (defensive: adapters synthesize some | ||
| 548 | // rows) — sticky membership still routes it to the agent. | ||
| 549 | legacyActivity("task.completed", { taskId: "a1", status: "completed", summary: "done" }), | ||
| 550 | ]); | ||
| 551 | expect(agents).toHaveLength(1); | ||
| 552 | expect(agents[0]!.status).toBe("completed"); | ||
| 553 | expect(agents[0]!.result).toBe("done"); | ||
| 554 | }); | ||
| 555 | }); | ||
| 556 | |||
| 557 | describe("session-derived interruption", () => { | ||
| 558 | it("dead session interrupts live agents but preserves idle and settled", () => { | ||
| 559 | const rows = [ | ||
| 560 | activity("task.started", { taskId: "live-1", taskType: "local_agent" }), | ||
| 561 | activity("task.started", { taskId: "idle-1", taskType: "local_agent" }), | ||
| 562 | activity("task.updated", { taskId: "idle-1", status: "idle" }), | ||
| 563 | activity("task.started", { taskId: "done-1", taskType: "local_agent" }), | ||
| 564 | activity("task.completed", { taskId: "done-1", status: "completed" }), | ||
| 565 | ]; | ||
| 566 | const dead = foldSubagentActivities(rows, { sessionLive: false }); | ||
| 567 | expect(dead.find((agent) => agent.id === "live-1")?.status).toBe("interrupted"); | ||
| 568 | expect(dead.find((agent) => agent.id === "idle-1")?.status).toBe("idle"); | ||
| 569 | expect(dead.find((agent) => agent.id === "done-1")?.status).toBe("completed"); | ||
| 570 | const alive = foldSubagentActivities(rows, { sessionLive: true }); | ||
| 571 | expect(alive.find((agent) => agent.id === "live-1")?.status).toBe("running"); | ||
| 572 | }); | ||
| 573 | }); | ||
| 574 | |||
| 575 | describe("terminal robustness", () => { | ||
| 576 | it("task.updated creating an agent (start row aged out) counts one activation", () => { | ||
| 577 | const agents = fold([ | ||
| 578 | activity("task.updated", { taskId: "orphan-u", status: "running", role: "worker" }), | ||
| 579 | ]); | ||
| 580 | expect(agents).toHaveLength(1); | ||
| 581 | expect(agents[0]!.activationCount).toBe(1); | ||
| 582 | expect(agents[0]!.status).toBe("running"); | ||
| 583 | }); | ||
| 584 | |||
| 585 | it("a late start after a terminal task.updated does not reopen the run", () => { | ||
| 586 | const agents = fold([ | ||
| 587 | activity("task.updated", { taskId: "t1", status: "failed", role: "worker" }), | ||
| 588 | activity("task.started", { taskId: "t1", taskType: "local_agent", title: "Late" }), | ||
| 589 | ]); | ||
| 590 | expect(agents).toHaveLength(1); | ||
| 591 | expect(agents[0]!.status).toBe("failed"); | ||
| 592 | expect(agents[0]!.title).toBe("Late"); | ||
| 593 | }); | ||
| 594 | |||
| 595 | it("a completion after a terminal task.updated still enriches result and usage", () => { | ||
| 596 | // Claude commonly emits terminal task.updated before task.completed; | ||
| 597 | // the completion carries the summary and final usage the update lacked. | ||
| 598 | const agents = fold([ | ||
| 599 | activity("task.started", { taskId: "te-1", taskType: "local_agent" }), | ||
| 600 | activity( | ||
| 601 | "task.updated", | ||
| 602 | { taskId: "te-1", status: "completed", endedAt: "2026-08-01T10:59:00.000Z" }, | ||
| 603 | "2026-08-01T11:00:00.000Z", | ||
| 604 | ), | ||
| 605 | activity( | ||
| 606 | "task.completed", | ||
| 607 | { | ||
| 608 | taskId: "te-1", | ||
| 609 | status: "completed", | ||
| 610 | summary: "final answer", | ||
| 611 | typedUsage: { totalTokens: 4200, toolUses: 7 }, | ||
| 612 | }, | ||
| 613 | "2026-08-01T11:00:01.000Z", | ||
| 614 | ), | ||
| 615 | ]); | ||
| 616 | const agent = agents[0]!; | ||
| 617 | expect(agent.status).toBe("completed"); | ||
| 618 | expect(agent.result).toBe("final answer"); | ||
| 619 | expect(agent.usage?.totalTokens).toBe(4200); | ||
| 620 | // Timestamps stay pinned to the transition that settled the run. | ||
| 621 | expect(agent.completedAt).toBe("2026-08-01T10:59:00.000Z"); | ||
| 622 | }); | ||
| 623 | |||
| 624 | it("duplicate completions keep the FIRST result, not the last", () => { | ||
| 625 | const agents = fold([ | ||
| 626 | activity("task.started", { taskId: "t2", taskType: "local_agent" }), | ||
| 627 | activity("task.completed", { taskId: "t2", status: "completed", summary: "first result" }), | ||
| 628 | activity("task.completed", { taskId: "t2", status: "completed", summary: "second result" }), | ||
| 629 | ]); | ||
| 630 | expect(agents[0]!.result).toBe("first result"); | ||
| 631 | }); | ||
| 632 | |||
| 633 | it("provider endedAt wins over ingestion time on the settling transition", () => { | ||
| 634 | const agents = fold([ | ||
| 635 | activity("task.started", { taskId: "t3", taskType: "local_agent" }), | ||
| 636 | activity( | ||
| 637 | "task.updated", | ||
| 638 | { taskId: "t3", status: "failed", endedAt: "2026-08-01T09:59:59.000Z" }, | ||
| 639 | "2026-08-01T10:00:30.000Z", | ||
| 640 | ), | ||
| 641 | ]); | ||
| 642 | expect(agents[0]!.completedAt).toBe("2026-08-01T09:59:59.000Z"); | ||
| 643 | }); | ||
| 644 | |||
| 645 | it("workflow retries count each attempt once", () => { | ||
| 646 | const agents = fold([ | ||
| 647 | activity("task.progress", { | ||
| 648 | taskId: "wf-r:wf:0", | ||
| 649 | parentAgentId: "wf-r", | ||
| 650 | status: "running", | ||
| 651 | attempt: 1, | ||
| 652 | }), | ||
| 653 | activity("task.progress", { | ||
| 654 | taskId: "wf-r:wf:0", | ||
| 655 | parentAgentId: "wf-r", | ||
| 656 | status: "failed", | ||
| 657 | attempt: 1, | ||
| 658 | }), | ||
| 659 | activity("task.progress", { | ||
| 660 | taskId: "wf-r:wf:0", | ||
| 661 | parentAgentId: "wf-r", | ||
| 662 | status: "running", | ||
| 663 | attempt: 2, | ||
| 664 | }), | ||
| 665 | ]); | ||
| 666 | expect(agents[0]!.activationCount).toBe(2); | ||
| 667 | }); | ||
| 668 | }); | ||
| 669 | |||
| 670 | describe("phase membership", () => { | ||
| 671 | it("members with unknown phase indices land in unphasedMembers, never vanish", () => { | ||
| 672 | const model = deriveAgentPanelModel({ | ||
| 673 | agents: fold([ | ||
| 674 | activity("task.started", { | ||
| 675 | taskId: "wf-p", | ||
| 676 | taskType: "local_workflow", | ||
| 677 | phases: [{ index: 0, title: "Only phase" }], | ||
| 678 | }), | ||
| 679 | activity("task.progress", { | ||
| 680 | taskId: "wf-p:wf:0", | ||
| 681 | parentAgentId: "wf-p", | ||
| 682 | status: "running", | ||
| 683 | phaseIndex: 0, | ||
| 684 | }), | ||
| 685 | activity("task.progress", { | ||
| 686 | taskId: "wf-p:wf:9", | ||
| 687 | parentAgentId: "wf-p", | ||
| 688 | status: "running", | ||
| 689 | phaseIndex: 9, | ||
| 690 | }), | ||
| 691 | ]), | ||
| 692 | }); | ||
| 693 | const group = model.workflows[0]!; | ||
| 694 | const visible = [ | ||
| 695 | ...group.phases.flatMap((phase) => phase.members), | ||
| 696 | ...group.unphasedMembers, | ||
| 697 | ].map((member) => member.id); | ||
| 698 | expect(visible).toContain("wf-p:wf:0"); | ||
| 699 | expect(visible).toContain("wf-p:wf:9"); | ||
| 700 | }); | ||
| 701 | }); | ||
| 702 | |||
| 703 | describe("coordinator settle cascade", () => { | ||
| 704 | it("members without their own terminal row settle when the coordinator does", () => { | ||
| 705 | const agents = fold([ | ||
| 706 | activity("task.started", { taskId: "wf-1", taskType: "local_workflow" }), | ||
| 707 | activity("task.progress", { | ||
| 708 | taskId: "wf-1:wf:0", | ||
| 709 | title: "stalled member", | ||
| 710 | status: "running", | ||
| 711 | parentAgentId: "wf-1", | ||
| 712 | }), | ||
| 713 | activity("task.completed", { | ||
| 714 | taskId: "wf-1", | ||
| 715 | status: "completed", | ||
| 716 | taskType: "local_workflow", | ||
| 717 | }), | ||
| 718 | ]); | ||
| 719 | const member = agents.find((agent) => agent.id === "wf-1:wf:0"); | ||
| 720 | expect(member?.status).toBe("completed"); | ||
| 721 | expect(member?.completedAt).not.toBeNull(); | ||
| 722 | }); | ||
| 723 | |||
| 724 | it("a failed coordinator marks unfinished members interrupted, not completed", () => { | ||
| 725 | const agents = fold([ | ||
| 726 | activity("task.started", { taskId: "wf-2", taskType: "local_workflow" }), | ||
| 727 | activity("task.progress", { | ||
| 728 | taskId: "wf-2:wf:0", | ||
| 729 | status: "running", | ||
| 730 | parentAgentId: "wf-2", | ||
| 731 | }), | ||
| 732 | activity("task.completed", { taskId: "wf-2", status: "failed", taskType: "local_workflow" }), | ||
| 733 | ]); | ||
| 734 | const member = agents.find((agent) => agent.id === "wf-2:wf:0"); | ||
| 735 | expect(member?.status).toBe("interrupted"); | ||
| 736 | }); | ||
| 737 | }); | ||
| 738 | |||
| 739 | describe("task type classification is a denylist", () => { | ||
| 740 | it("unknown agent-flavored types (local_agent, future names) join the roster", () => { | ||
| 741 | const agents = fold([ | ||
| 742 | activity("task.started", { | ||
| 743 | taskId: "a1", | ||
| 744 | taskType: "local_agent", | ||
| 745 | title: "Math test 1", | ||
| 746 | role: "claude", | ||
| 747 | }), | ||
| 748 | activity("task.started", { taskId: "a2", taskType: "some_future_agent_kind", title: "X" }), | ||
| 749 | ]); | ||
| 750 | expect(agents.map((agent) => agent.id).toSorted()).toEqual(["a1", "a2"]); | ||
| 751 | }); | ||
| 752 | }); | ||
| 753 | |||
| 754 | describe("nested agents vs subagent shells", () => { | ||
| 755 | it("a nested agent (agentId + agent taskType) stays in the roster; its shells do not", () => { | ||
| 756 | const agents = fold([ | ||
| 757 | activity("task.started", { | ||
| 758 | taskId: "nested-1", | ||
| 759 | taskType: "local_agent", | ||
| 760 | agentId: "parent-agent", | ||
| 761 | title: "Nested researcher", | ||
| 762 | }), | ||
| 763 | activity("task.started", { | ||
| 764 | taskId: "shell-1", | ||
| 765 | taskType: "local_bash", | ||
| 766 | agentId: "parent-agent", | ||
| 767 | title: "Nested sleep", | ||
| 768 | }), | ||
| 769 | ]); | ||
| 770 | expect(agents.map((agent) => agent.id)).toEqual(["nested-1"]); | ||
| 771 | }); | ||
| 772 | }); | ||
packages/client-runtime/src/state/subagentRuntime.ts created+924| ... | @@ -0,0 +1,924 @@ | ||
| 1 | /** | ||
| 2 | * Native-provider subagent observability: a tolerant fold over persisted | ||
| 3 | * task.* / tool.* thread activities into orchestration-v2-shaped subagent | ||
| 4 | * state, plus the source-neutral panel model every client renders. | ||
| 5 | * | ||
| 6 | * This module is deliberately legacy-bridge code. When orchestration-v2's | ||
| 7 | * subagent projection is available for a thread, deriveAgentPanelModel | ||
| 8 | * prefers it (see the v2Projection parameter) and the fold is skipped; when | ||
| 9 | * the v1 orchestrator is retired this file is deleted. Field names and | ||
| 10 | * transition semantics copy the v2 stack (#4779) exactly so that swap is | ||
| 11 | * mechanical. | ||
| 12 | * | ||
| 13 | * Invariants encoded here trace to shipped bugs in the prior PRs (#4220, | ||
| 14 | * #3650, #4662): reusable identity vs one-shot activations, idle as a real | ||
| 15 | * nonterminal state, provider-specific usage merges, first-write terminal | ||
| 16 | * timestamps, reactivation clearing terminal detail, and order-robust | ||
| 17 | * folding (completion can create an agent; a late start only fills | ||
| 18 | * metadata). | ||
| 19 | */ | ||
| 20 | import type { OrchestrationThreadActivity } from "@t3tools/contracts"; | ||
| 21 | |||
| 22 | export type RuntimeSubagentStatus = | ||
| 23 | | "pending" | ||
| 24 | | "running" | ||
| 25 | | "waiting" | ||
| 26 | | "idle" | ||
| 27 | | "completed" | ||
| 28 | | "failed" | ||
| 29 | | "cancelled" | ||
| 30 | | "interrupted"; | ||
| 31 | |||
| 32 | export interface SubagentUsage { | ||
| 33 | readonly totalTokens: number; | ||
| 34 | readonly inputTokens?: number; | ||
| 35 | readonly cachedInputTokens?: number; | ||
| 36 | readonly outputTokens?: number; | ||
| 37 | readonly reasoningOutputTokens?: number; | ||
| 38 | readonly toolUses?: number; | ||
| 39 | readonly durationMs?: number; | ||
| 40 | } | ||
| 41 | |||
| 42 | export interface SubagentActivityEntry { | ||
| 43 | readonly at: string; | ||
| 44 | readonly summary: string; | ||
| 45 | } | ||
| 46 | |||
| 47 | export interface SubagentWorkflowPhase { | ||
| 48 | readonly index: number; | ||
| 49 | readonly title: string; | ||
| 50 | } | ||
| 51 | |||
| 52 | export interface SubagentRunHandles { | ||
| 53 | readonly runId?: string; | ||
| 54 | readonly scriptPath?: string; | ||
| 55 | readonly transcriptDir?: string; | ||
| 56 | readonly sessionUrl?: string; | ||
| 57 | } | ||
| 58 | |||
| 59 | export interface RuntimeSubagent { | ||
| 60 | readonly id: string; | ||
| 61 | readonly kind: "subagent" | "workflow" | "workflow_agent"; | ||
| 62 | readonly title: string; | ||
| 63 | readonly role: string | null; | ||
| 64 | readonly model: string | null; | ||
| 65 | readonly effort: string | null; | ||
| 66 | readonly status: RuntimeSubagentStatus; | ||
| 67 | readonly activationCount: number; | ||
| 68 | readonly usage: SubagentUsage | null; | ||
| 69 | readonly progress: string | null; | ||
| 70 | readonly lastToolName: string | null; | ||
| 71 | readonly result: string | null; | ||
| 72 | readonly error: string | null; | ||
| 73 | readonly outputFile: string | null; | ||
| 74 | readonly parentAgentId: string | null; | ||
| 75 | readonly agentIndex: number | null; | ||
| 76 | readonly phaseIndex: number | null; | ||
| 77 | readonly phaseTitle: string | null; | ||
| 78 | readonly attempt: number | null; | ||
| 79 | readonly workflowName: string | null; | ||
| 80 | readonly phases: ReadonlyArray<SubagentWorkflowPhase>; | ||
| 81 | readonly runHandles: SubagentRunHandles | null; | ||
| 82 | readonly recentActivity: ReadonlyArray<SubagentActivityEntry>; | ||
| 83 | readonly startedAt: string | null; | ||
| 84 | readonly completedAt: string | null; | ||
| 85 | readonly updatedAt: string; | ||
| 86 | } | ||
| 87 | |||
| 88 | const TERMINAL_STATUSES: ReadonlySet<RuntimeSubagentStatus> = new Set([ | ||
| 89 | "completed", | ||
| 90 | "failed", | ||
| 91 | "cancelled", | ||
| 92 | "interrupted", | ||
| 93 | ]); | ||
| 94 | |||
| 95 | export function isTerminalSubagentStatus(status: RuntimeSubagentStatus): boolean { | ||
| 96 | return TERMINAL_STATUSES.has(status); | ||
| 97 | } | ||
| 98 | |||
| 99 | /** Active = the user may still need to care while it runs. Idle is settled-ish | ||
| 100 | * but resumable; waiting counts as active because it needs the user. */ | ||
| 101 | export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean { | ||
| 102 | return status === "pending" || status === "running" || status === "waiting"; | ||
| 103 | } | ||
| 104 | |||
| 105 | const RECENT_ACTIVITY_LIMIT = 6; | ||
| 106 | const SUMMARY_CHAR_LIMIT = 180; | ||
| 107 | const ROSTER_LIMIT = 100; | ||
| 108 | |||
| 109 | /** | ||
| 110 | * True when this activity's payload does NOT belong on the Agents surface. | ||
| 111 | * Classification happens exactly once, server-side at ingestion | ||
| 112 | * (classifyTaskAgentKind → the persisted agentKind stamp); the client only | ||
| 113 | * reads it. Rows without a stamp — legacy threads, pre-stamp servers — are | ||
| 114 | * background by definition: they render in the ordinary work log, exactly | ||
| 115 | * as they did before this feature existed. | ||
| 116 | */ | ||
| 117 | export function isBackgroundTaskActivity(payload: Record<string, unknown>): boolean { | ||
| 118 | return payload.agentKind !== "agent"; | ||
| 119 | } | ||
| 120 | |||
| 121 | function bounded(value: string): string { | ||
| 122 | return value.length <= SUMMARY_CHAR_LIMIT ? value : `${value.slice(0, SUMMARY_CHAR_LIMIT - 1)}…`; | ||
| 123 | } | ||
| 124 | |||
| 125 | /** Appends to the ring buffer, deduping consecutive identical summaries. */ | ||
| 126 | function appendActivity( | ||
| 127 | entries: ReadonlyArray<SubagentActivityEntry>, | ||
| 128 | at: string, | ||
| 129 | summary: string, | ||
| 130 | ): ReadonlyArray<SubagentActivityEntry> { | ||
| 131 | const boundedSummary = bounded(summary); | ||
| 132 | if (entries.length > 0 && entries[entries.length - 1]?.summary === boundedSummary) { | ||
| 133 | return entries; | ||
| 134 | } | ||
| 135 | const next = [...entries, { at, summary: boundedSummary }]; | ||
| 136 | return next.length > RECENT_ACTIVITY_LIMIT ? next.slice(-RECENT_ACTIVITY_LIMIT) : next; | ||
| 137 | } | ||
| 138 | |||
| 139 | function asString(value: unknown): string | undefined { | ||
| 140 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; | ||
| 141 | } | ||
| 142 | |||
| 143 | function asCount(value: unknown): number | undefined { | ||
| 144 | return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; | ||
| 145 | } | ||
| 146 | |||
| 147 | function asUsage(value: unknown): SubagentUsage | undefined { | ||
| 148 | if (typeof value !== "object" || value === null) { | ||
| 149 | return undefined; | ||
| 150 | } | ||
| 151 | const record = value as Record<string, unknown>; | ||
| 152 | const totalTokens = asCount(record.totalTokens); | ||
| 153 | if (totalTokens === undefined) { | ||
| 154 | return undefined; | ||
| 155 | } | ||
| 156 | const usage: { | ||
| 157 | totalTokens: number; | ||
| 158 | inputTokens?: number; | ||
| 159 | cachedInputTokens?: number; | ||
| 160 | outputTokens?: number; | ||
| 161 | reasoningOutputTokens?: number; | ||
| 162 | toolUses?: number; | ||
| 163 | durationMs?: number; | ||
| 164 | } = { totalTokens }; | ||
| 165 | const inputTokens = asCount(record.inputTokens); | ||
| 166 | if (inputTokens !== undefined) usage.inputTokens = inputTokens; | ||
| 167 | const cachedInputTokens = asCount(record.cachedInputTokens); | ||
| 168 | if (cachedInputTokens !== undefined) usage.cachedInputTokens = cachedInputTokens; | ||
| 169 | const outputTokens = asCount(record.outputTokens); | ||
| 170 | if (outputTokens !== undefined) usage.outputTokens = outputTokens; | ||
| 171 | const reasoningOutputTokens = asCount(record.reasoningOutputTokens); | ||
| 172 | if (reasoningOutputTokens !== undefined) usage.reasoningOutputTokens = reasoningOutputTokens; | ||
| 173 | const toolUses = asCount(record.toolUses); | ||
| 174 | if (toolUses !== undefined) usage.toolUses = toolUses; | ||
| 175 | const durationMs = asCount(record.durationMs); | ||
| 176 | if (durationMs !== undefined) usage.durationMs = durationMs; | ||
| 177 | return usage; | ||
| 178 | } | ||
| 179 | |||
| 180 | /** | ||
| 181 | * Provider-specific usage merge (#4779 semantics, verbatim): | ||
| 182 | * - max-merge (Codex-style cumulative frames): field-wise maximum, idempotent | ||
| 183 | * under duplicate or late frames. Cumulative totals never shrink. | ||
| 184 | * - accumulate (Claude-style activation deltas): not needed at this layer — | ||
| 185 | * Claude's task_progress usage is itself cumulative per task, so the fold | ||
| 186 | * also max-merges. The distinction matters when v2 sums activations. | ||
| 187 | * Field-wise: a terminal payload carrying only totalTokens must not wipe a | ||
| 188 | * known breakdown. | ||
| 189 | */ | ||
| 190 | function mergeUsageMax( | ||
| 191 | current: SubagentUsage | null, | ||
| 192 | incoming: SubagentUsage | undefined, | ||
| 193 | ): SubagentUsage | null { | ||
| 194 | if (!incoming) { | ||
| 195 | return current; | ||
| 196 | } | ||
| 197 | if (!current) { | ||
| 198 | return incoming; | ||
| 199 | } | ||
| 200 | const pick = (a: number | undefined, b: number | undefined): number | undefined => | ||
| 201 | a === undefined ? b : b === undefined ? a : Math.max(a, b); | ||
| 202 | const merged: { | ||
| 203 | totalTokens: number; | ||
| 204 | inputTokens?: number; | ||
| 205 | cachedInputTokens?: number; | ||
| 206 | outputTokens?: number; | ||
| 207 | reasoningOutputTokens?: number; | ||
| 208 | toolUses?: number; | ||
| 209 | durationMs?: number; | ||
| 210 | } = { totalTokens: Math.max(current.totalTokens, incoming.totalTokens) }; | ||
| 211 | const inputTokens = pick(current.inputTokens, incoming.inputTokens); | ||
| 212 | if (inputTokens !== undefined) merged.inputTokens = inputTokens; | ||
| 213 | const cachedInputTokens = pick(current.cachedInputTokens, incoming.cachedInputTokens); | ||
| 214 | if (cachedInputTokens !== undefined) merged.cachedInputTokens = cachedInputTokens; | ||
| 215 | const outputTokens = pick(current.outputTokens, incoming.outputTokens); | ||
| 216 | if (outputTokens !== undefined) merged.outputTokens = outputTokens; | ||
| 217 | const reasoningOutputTokens = pick(current.reasoningOutputTokens, incoming.reasoningOutputTokens); | ||
| 218 | if (reasoningOutputTokens !== undefined) merged.reasoningOutputTokens = reasoningOutputTokens; | ||
| 219 | const toolUses = pick(current.toolUses, incoming.toolUses); | ||
| 220 | if (toolUses !== undefined) merged.toolUses = toolUses; | ||
| 221 | const durationMs = pick(current.durationMs, incoming.durationMs); | ||
| 222 | if (durationMs !== undefined) merged.durationMs = durationMs; | ||
| 223 | return merged; | ||
| 224 | } | ||
| 225 | |||
| 226 | interface MutableAgent { | ||
| 227 | id: string; | ||
| 228 | kind: RuntimeSubagent["kind"]; | ||
| 229 | title: string; | ||
| 230 | role: string | null; | ||
| 231 | model: string | null; | ||
| 232 | effort: string | null; | ||
| 233 | status: RuntimeSubagentStatus; | ||
| 234 | activationCount: number; | ||
| 235 | usage: SubagentUsage | null; | ||
| 236 | progress: string | null; | ||
| 237 | lastToolName: string | null; | ||
| 238 | result: string | null; | ||
| 239 | error: string | null; | ||
| 240 | outputFile: string | null; | ||
| 241 | parentAgentId: string | null; | ||
| 242 | agentIndex: number | null; | ||
| 243 | phaseIndex: number | null; | ||
| 244 | phaseTitle: string | null; | ||
| 245 | attempt: number | null; | ||
| 246 | workflowName: string | null; | ||
| 247 | phases: ReadonlyArray<SubagentWorkflowPhase>; | ||
| 248 | runHandles: SubagentRunHandles | null; | ||
| 249 | recentActivity: ReadonlyArray<SubagentActivityEntry>; | ||
| 250 | startedAt: string | null; | ||
| 251 | completedAt: string | null; | ||
| 252 | updatedAt: string; | ||
| 253 | } | ||
| 254 | |||
| 255 | function kindFromPayload( | ||
| 256 | payload: Record<string, unknown>, | ||
| 257 | agentId: string, | ||
| 258 | ): RuntimeSubagent["kind"] { | ||
| 259 | if (asString(payload.taskType) === "local_workflow") { | ||
| 260 | return "workflow"; | ||
| 261 | } | ||
| 262 | if (payload.parentAgentId !== undefined || agentId.includes(":wf:")) { | ||
| 263 | return "workflow_agent"; | ||
| 264 | } | ||
| 265 | return "subagent"; | ||
| 266 | } | ||
| 267 | |||
| 268 | /** Completion can create an agent (its start may have aged out of retention). */ | ||
| 269 | function getOrCreate( | ||
| 270 | agents: Map<string, MutableAgent>, | ||
| 271 | id: string, | ||
| 272 | payload: Record<string, unknown>, | ||
| 273 | at: string, | ||
| 274 | ): MutableAgent { | ||
| 275 | const existing = agents.get(id); | ||
| 276 | if (existing) { | ||
| 277 | return existing; | ||
| 278 | } | ||
| 279 | const created: MutableAgent = { | ||
| 280 | id, | ||
| 281 | kind: kindFromPayload(payload, id), | ||
| 282 | title: asString(payload.title) ?? asString(payload.detail) ?? id, | ||
| 283 | role: asString(payload.role) ?? null, | ||
| 284 | model: asString(payload.model) ?? null, | ||
| 285 | effort: asString(payload.effort) ?? null, | ||
| 286 | status: "pending", | ||
| 287 | activationCount: 0, | ||
| 288 | usage: null, | ||
| 289 | progress: null, | ||
| 290 | lastToolName: null, | ||
| 291 | result: null, | ||
| 292 | error: null, | ||
| 293 | outputFile: null, | ||
| 294 | parentAgentId: asString(payload.parentAgentId) ?? null, | ||
| 295 | agentIndex: asCount(payload.agentIndex) ?? null, | ||
| 296 | phaseIndex: asCount(payload.phaseIndex) ?? null, | ||
| 297 | phaseTitle: asString(payload.phaseTitle) ?? null, | ||
| 298 | attempt: asCount(payload.attempt) ?? null, | ||
| 299 | workflowName: asString(payload.workflowName) ?? null, | ||
| 300 | phases: [], | ||
| 301 | runHandles: null, | ||
| 302 | recentActivity: [], | ||
| 303 | startedAt: null, | ||
| 304 | completedAt: null, | ||
| 305 | updatedAt: at, | ||
| 306 | }; | ||
| 307 | agents.set(id, created); | ||
| 308 | return created; | ||
| 309 | } | ||
| 310 | |||
| 311 | /** Metadata fill from any payload: never downgrades known values to null. */ | ||
| 312 | function fillMetadata(agent: MutableAgent, payload: Record<string, unknown>): void { | ||
| 313 | const title = asString(payload.title); | ||
| 314 | if (title) agent.title = title; | ||
| 315 | const role = asString(payload.role); | ||
| 316 | if (role) agent.role = role; | ||
| 317 | const model = asString(payload.model); | ||
| 318 | if (model) agent.model = model; | ||
| 319 | const effort = asString(payload.effort); | ||
| 320 | if (effort) agent.effort = effort; | ||
| 321 | const parentAgentId = asString(payload.parentAgentId); | ||
| 322 | if (parentAgentId) { | ||
| 323 | agent.parentAgentId = parentAgentId; | ||
| 324 | if (agent.kind === "subagent") agent.kind = "workflow_agent"; | ||
| 325 | } | ||
| 326 | const workflowName = asString(payload.workflowName); | ||
| 327 | if (workflowName) agent.workflowName = workflowName; | ||
| 328 | if (asString(payload.taskType) === "local_workflow") agent.kind = "workflow"; | ||
| 329 | const agentIndex = asCount(payload.agentIndex); | ||
| 330 | if (agentIndex !== undefined) agent.agentIndex = agentIndex; | ||
| 331 | const phaseIndex = asCount(payload.phaseIndex); | ||
| 332 | if (phaseIndex !== undefined) agent.phaseIndex = phaseIndex; | ||
| 333 | const phaseTitle = asString(payload.phaseTitle); | ||
| 334 | if (phaseTitle) agent.phaseTitle = phaseTitle; | ||
| 335 | const attempt = asCount(payload.attempt); | ||
| 336 | if (attempt !== undefined) { | ||
| 337 | // A new attempt on a workflow slot is a reactivation of the same | ||
| 338 | // identity: clear the previous attempt's terminal detail so the status | ||
| 339 | // transition (terminal → running, in applyStatus) reads as a fresh run. | ||
| 340 | // The activation bump lives ONLY in applyStatus — bumping here too | ||
| 341 | // counted every retry twice (review finding: two attempts read "run 3"). | ||
| 342 | if (agent.attempt !== null && attempt > agent.attempt) { | ||
| 343 | agent.result = null; | ||
| 344 | agent.error = null; | ||
| 345 | agent.completedAt = null; | ||
| 346 | } | ||
| 347 | agent.attempt = attempt; | ||
| 348 | } | ||
| 349 | const outputFile = asString(payload.outputFile); | ||
| 350 | if (outputFile) agent.outputFile = outputFile; | ||
| 351 | if (Array.isArray(payload.phases)) { | ||
| 352 | const phases: SubagentWorkflowPhase[] = []; | ||
| 353 | for (const entry of payload.phases) { | ||
| 354 | if (typeof entry !== "object" || entry === null) continue; | ||
| 355 | const record = entry as Record<string, unknown>; | ||
| 356 | const index = asCount(record.index); | ||
| 357 | const phaseName = asString(record.title); | ||
| 358 | if (index !== undefined && phaseName) { | ||
| 359 | phases.push({ index, title: phaseName }); | ||
| 360 | } | ||
| 361 | } | ||
| 362 | if (phases.length > 0) { | ||
| 363 | agent.phases = phases.slice().sort((a, b) => a.index - b.index); | ||
| 364 | } | ||
| 365 | } | ||
| 366 | if (typeof payload.runHandles === "object" && payload.runHandles !== null) { | ||
| 367 | const record = payload.runHandles as Record<string, unknown>; | ||
| 368 | const runHandles: { | ||
| 369 | runId?: string; | ||
| 370 | scriptPath?: string; | ||
| 371 | transcriptDir?: string; | ||
| 372 | sessionUrl?: string; | ||
| 373 | } = {}; | ||
| 374 | const runId = asString(record.runId); | ||
| 375 | if (runId) runHandles.runId = runId; | ||
| 376 | const scriptPath = asString(record.scriptPath); | ||
| 377 | if (scriptPath) runHandles.scriptPath = scriptPath; | ||
| 378 | const transcriptDir = asString(record.transcriptDir); | ||
| 379 | if (transcriptDir) runHandles.transcriptDir = transcriptDir; | ||
| 380 | // Defense-in-depth: the adapter already sanitizes, but payloads are not | ||
| 381 | // schema-validated on the read path (shipped XSS lesson). | ||
| 382 | const sessionUrl = asString(record.sessionUrl); | ||
| 383 | if (sessionUrl && /^https?:\/\//i.test(sessionUrl)) runHandles.sessionUrl = sessionUrl; | ||
| 384 | if (Object.keys(runHandles).length > 0) { | ||
| 385 | agent.runHandles = { ...agent.runHandles, ...runHandles }; | ||
| 386 | } | ||
| 387 | } | ||
| 388 | } | ||
| 389 | |||
| 390 | function applyStatus(agent: MutableAgent, status: RuntimeSubagentStatus, at: string): void { | ||
| 391 | const wasTerminal = isTerminalSubagentStatus(agent.status); | ||
| 392 | const isTerminal = isTerminalSubagentStatus(status); | ||
| 393 | if (wasTerminal && isTerminal) { | ||
| 394 | // Duplicate terminal events are idempotent: first write wins, timestamps | ||
| 395 | // don't slide. | ||
| 396 | return; | ||
| 397 | } | ||
| 398 | if ((wasTerminal || agent.status === "idle") && (status === "running" || status === "pending")) { | ||
| 399 | // Reactivation: same identity, new run. Clear the previous run's terminal | ||
| 400 | // detail so a live card never shows the prior run's output. | ||
| 401 | agent.activationCount += 1; | ||
| 402 | agent.result = null; | ||
| 403 | agent.error = null; | ||
| 404 | agent.completedAt = null; | ||
| 405 | if (status === "running") { | ||
| 406 | agent.startedAt = at; | ||
| 407 | } | ||
| 408 | } | ||
| 409 | if (status === "running" && agent.startedAt === null) { | ||
| 410 | agent.startedAt = at; | ||
| 411 | } | ||
| 412 | if (isTerminal && agent.completedAt === null) { | ||
| 413 | agent.completedAt = at; | ||
| 414 | } | ||
| 415 | agent.status = status; | ||
| 416 | } | ||
| 417 | |||
| 418 | // Map, not object literal: payloads aren't schema-validated on the read | ||
| 419 | // path, so a status like "toString" must miss instead of resolving an | ||
| 420 | // inherited Function through the prototype chain. | ||
| 421 | const TASK_COMPLETED_STATUS: ReadonlyMap<string, RuntimeSubagentStatus> = new Map([ | ||
| 422 | ["completed", "completed"], | ||
| 423 | ["failed", "failed"], | ||
| 424 | ["stopped", "interrupted"], | ||
| 425 | ]); | ||
| 426 | |||
| 427 | const KNOWN_STATUSES: ReadonlySet<string> = new Set([ | ||
| 428 | "pending", | ||
| 429 | "running", | ||
| 430 | "waiting", | ||
| 431 | "idle", | ||
| 432 | "completed", | ||
| 433 | "failed", | ||
| 434 | "cancelled", | ||
| 435 | "interrupted", | ||
| 436 | ]); | ||
| 437 | |||
| 438 | function asRuntimeStatus(value: unknown): RuntimeSubagentStatus | undefined { | ||
| 439 | return typeof value === "string" && KNOWN_STATUSES.has(value) | ||
| 440 | ? (value as RuntimeSubagentStatus) | ||
| 441 | : undefined; | ||
| 442 | } | ||
| 443 | |||
| 444 | /** | ||
| 445 | * Folds a thread's persisted activities into subagent state. Tolerant by | ||
| 446 | * construction: malformed rows are skipped individually; unknown kinds are | ||
| 447 | * ignored. Pure — memoize by activity-list identity at the atom layer. | ||
| 448 | * | ||
| 449 | * sessionLive=false derives interruption: background tasks die with their | ||
| 450 | * provider session, so agents whose terminal rows were lost (server | ||
| 451 | * restart, crash) must not read as running forever (review finding: a dead | ||
| 452 | * session left a panel full of "Working" agents while the sidebar showed | ||
| 453 | * nothing). Idle is preserved — a resumable Codex child stays resumable. | ||
| 454 | */ | ||
| 455 | export function foldSubagentActivities( | ||
| 456 | activities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 457 | options?: { readonly sessionLive?: boolean }, | ||
| 458 | ): ReadonlyArray<RuntimeSubagent> { | ||
| 459 | const agents = new Map<string, MutableAgent>(); | ||
| 460 | |||
| 461 | for (const activity of activities) { | ||
| 462 | if (typeof activity.payload !== "object" || activity.payload === null) { | ||
| 463 | continue; | ||
| 464 | } | ||
| 465 | const payload = activity.payload as Record<string, unknown>; | ||
| 466 | const at = activity.createdAt; | ||
| 467 | |||
| 468 | switch (activity.kind) { | ||
| 469 | case "task.started": { | ||
| 470 | const taskId = asString(payload.taskId); | ||
| 471 | if (!taskId) break; | ||
| 472 | // Only real agents join the roster. Shells, monitors, and plan-mode | ||
| 473 | // tasks are background work — they render in the ordinary work log, | ||
| 474 | // not the Agents surface (a "Run 12s stall" shell is not a subagent). | ||
| 475 | if (isBackgroundTaskActivity(payload)) break; | ||
| 476 | const agent = getOrCreate(agents, taskId, payload, at); | ||
| 477 | fillMetadata(agent, payload); | ||
| 478 | // Order-robustness: a start row arriving after a terminal state is a | ||
| 479 | // late/out-of-order delivery and only fills metadata — it must not | ||
| 480 | // reopen the run. Reactivation comes exclusively from explicit | ||
| 481 | // status transitions (task.updated / progress status). Guard on the | ||
| 482 | // status itself, not activationCount: a task first seen via a | ||
| 483 | // terminal task.updated has zero activations but is still settled | ||
| 484 | // (review finding: a late start reopened a failed child). | ||
| 485 | if (agent.activationCount === 0 && !isTerminalSubagentStatus(agent.status)) { | ||
| 486 | agent.activationCount = 1; | ||
| 487 | agent.startedAt = agent.startedAt ?? at; | ||
| 488 | agent.status = "running"; | ||
| 489 | } else if (agent.status === "idle") { | ||
| 490 | applyStatus(agent, "running", at); | ||
| 491 | } | ||
| 492 | const detail = asString(payload.detail); | ||
| 493 | if (detail && agent.title === agent.id) agent.title = detail; | ||
| 494 | agent.updatedAt = at; | ||
| 495 | break; | ||
| 496 | } | ||
| 497 | case "task.progress": { | ||
| 498 | const taskId = asString(payload.taskId); | ||
| 499 | if (!taskId) break; | ||
| 500 | // Membership is sticky per taskId: rows after the first (terminal | ||
| 501 | // rows often carry only taskId+status, no marker fields) inherit the | ||
| 502 | // first row's classification instead of being re-judged. | ||
| 503 | if (!agents.has(taskId) && isBackgroundTaskActivity(payload)) break; | ||
| 504 | const agent = getOrCreate(agents, taskId, payload, at); | ||
| 505 | fillMetadata(agent, payload); | ||
| 506 | if (agent.activationCount === 0) agent.activationCount = 1; | ||
| 507 | const explicitStatus = asRuntimeStatus(payload.status); | ||
| 508 | if (explicitStatus) { | ||
| 509 | applyStatus(agent, explicitStatus, at); | ||
| 510 | } else if (!isTerminalSubagentStatus(agent.status) && agent.status !== "idle") { | ||
| 511 | applyStatus(agent, "running", at); | ||
| 512 | } | ||
| 513 | const summary = asString(payload.summary); | ||
| 514 | if (summary) { | ||
| 515 | agent.progress = bounded(summary); | ||
| 516 | agent.recentActivity = appendActivity(agent.recentActivity, at, summary); | ||
| 517 | } | ||
| 518 | const lastToolName = asString(payload.lastToolName); | ||
| 519 | if (lastToolName) { | ||
| 520 | agent.lastToolName = lastToolName; | ||
| 521 | if (!summary) { | ||
| 522 | agent.recentActivity = appendActivity(agent.recentActivity, at, `▸ ${lastToolName}`); | ||
| 523 | } | ||
| 524 | } | ||
| 525 | const error = asString(payload.error); | ||
| 526 | if (error) agent.error = bounded(error); | ||
| 527 | agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage)); | ||
| 528 | agent.updatedAt = at; | ||
| 529 | break; | ||
| 530 | } | ||
| 531 | case "task.updated": { | ||
| 532 | const taskId = asString(payload.taskId); | ||
| 533 | if (!taskId) break; | ||
| 534 | // Membership is sticky per taskId: rows after the first (terminal | ||
| 535 | // rows often carry only taskId+status, no marker fields) inherit the | ||
| 536 | // first row's classification instead of being re-judged. | ||
| 537 | if (!agents.has(taskId) && isBackgroundTaskActivity(payload)) break; | ||
| 538 | const agent = getOrCreate(agents, taskId, payload, at); | ||
| 539 | fillMetadata(agent, payload); | ||
| 540 | // A task first seen via task.updated (start row aged out) has run at | ||
| 541 | // least once — zero activations would misreport "run 0" and let a | ||
| 542 | // later start row treat it as never-started (review finding). | ||
| 543 | if (agent.activationCount === 0) agent.activationCount = 1; | ||
| 544 | const wasTerminal = isTerminalSubagentStatus(agent.status); | ||
| 545 | const status = asRuntimeStatus(payload.status); | ||
| 546 | if (status) applyStatus(agent, status, at); | ||
| 547 | const error = asString(payload.error); | ||
| 548 | if (error) agent.error = bounded(error); | ||
| 549 | // Provider end time beats ingestion time for the transition that | ||
| 550 | // actually settled the run (applyStatus fills completedAt with the | ||
| 551 | // activity timestamp first, so check the transition, not null). | ||
| 552 | const endedAt = asString(payload.endedAt); | ||
| 553 | if (endedAt && !wasTerminal && isTerminalSubagentStatus(agent.status)) { | ||
| 554 | agent.completedAt = endedAt; | ||
| 555 | } | ||
| 556 | agent.updatedAt = at; | ||
| 557 | break; | ||
| 558 | } | ||
| 559 | case "task.completed": { | ||
| 560 | const taskId = asString(payload.taskId); | ||
| 561 | if (!taskId) break; | ||
| 562 | // Membership is sticky per taskId: rows after the first (terminal | ||
| 563 | // rows often carry only taskId+status, no marker fields) inherit the | ||
| 564 | // first row's classification instead of being re-judged. | ||
| 565 | if (!agents.has(taskId) && isBackgroundTaskActivity(payload)) break; | ||
| 566 | const agent = getOrCreate(agents, taskId, payload, at); | ||
| 567 | fillMetadata(agent, payload); | ||
| 568 | if (agent.activationCount === 0) agent.activationCount = 1; | ||
| 569 | // Already-terminal: status and timestamps are frozen (first write | ||
| 570 | // wins, duplicates must not slide them) but the completion still | ||
| 571 | // ENRICHES — Claude commonly emits terminal task.updated before | ||
| 572 | // task.completed, and the completion carries the result summary and | ||
| 573 | // final usage the update lacked (review finding: the early return | ||
| 574 | // dropped both). Fill-if-missing keeps duplicate completions from | ||
| 575 | // replacing the first result. | ||
| 576 | const summary = asString(payload.summary) ?? asString(payload.detail); | ||
| 577 | if (isTerminalSubagentStatus(agent.status)) { | ||
| 578 | if (summary) { | ||
| 579 | if (agent.status === "failed") { | ||
| 580 | agent.error = agent.error ?? bounded(summary); | ||
| 581 | } else { | ||
| 582 | agent.result = agent.result ?? bounded(summary); | ||
| 583 | } | ||
| 584 | } | ||
| 585 | agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage)); | ||
| 586 | break; | ||
| 587 | } | ||
| 588 | const status = TASK_COMPLETED_STATUS.get(asString(payload.status) ?? "") ?? "completed"; | ||
| 589 | applyStatus(agent, status, at); | ||
| 590 | if (summary) { | ||
| 591 | if (status === "failed") { | ||
| 592 | agent.error = agent.error ?? bounded(summary); | ||
| 593 | } else { | ||
| 594 | agent.result = bounded(summary); | ||
| 595 | } | ||
| 596 | } | ||
| 597 | agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage)); | ||
| 598 | agent.updatedAt = at; | ||
| 599 | break; | ||
| 600 | } | ||
| 601 | case "tool.progress": { | ||
| 602 | // Agent-owned heartbeat: "what it's doing right now". | ||
| 603 | const taskId = asString(payload.taskId); | ||
| 604 | if (!taskId) break; | ||
| 605 | const agent = agents.get(taskId); | ||
| 606 | if (!agent) break; | ||
| 607 | const toolName = asString(payload.toolName); | ||
| 608 | if (toolName) { | ||
| 609 | agent.lastToolName = toolName; | ||
| 610 | agent.recentActivity = appendActivity(agent.recentActivity, at, `▸ ${toolName}`); | ||
| 611 | } | ||
| 612 | agent.updatedAt = at; | ||
| 613 | break; | ||
| 614 | } | ||
| 615 | default: | ||
| 616 | break; | ||
| 617 | } | ||
| 618 | } | ||
| 619 | |||
| 620 | // Consistency pass: when a workflow coordinator has settled, members that | ||
| 621 | // never received their own terminal row cannot still be in-flight — the | ||
| 622 | // run is over. Cascade the coordinator's outcome so stalled member rows | ||
| 623 | // don't read as working forever (live-test finding: statuses drifted | ||
| 624 | // whenever member terminal rows were lost or never emitted). | ||
| 625 | for (const agent of agents.values()) { | ||
| 626 | if (agent.kind !== "workflow" || !isTerminalSubagentStatus(agent.status)) { | ||
| 627 | continue; | ||
| 628 | } | ||
| 629 | for (const member of agents.values()) { | ||
| 630 | if (member.parentAgentId !== agent.id) { | ||
| 631 | continue; | ||
| 632 | } | ||
| 633 | if (isTerminalSubagentStatus(member.status) || member.status === "idle") { | ||
| 634 | continue; | ||
| 635 | } | ||
| 636 | member.status = agent.status === "completed" ? "completed" : "interrupted"; | ||
| 637 | member.completedAt = member.completedAt ?? agent.completedAt ?? agent.updatedAt; | ||
| 638 | member.updatedAt = agent.updatedAt; | ||
| 639 | } | ||
| 640 | } | ||
| 641 | |||
| 642 | // Session death orphans every live agent: no process remains to finish | ||
| 643 | // them. Mirrors the server-side liveness registry clearing on | ||
| 644 | // session.exited, so panel and sidebar can never disagree. | ||
| 645 | if (options?.sessionLive === false) { | ||
| 646 | for (const agent of agents.values()) { | ||
| 647 | if (isActiveSubagentStatus(agent.status)) { | ||
| 648 | agent.status = "interrupted"; | ||
| 649 | agent.completedAt = agent.completedAt ?? agent.updatedAt; | ||
| 650 | } | ||
| 651 | } | ||
| 652 | } | ||
| 653 | |||
| 654 | let roster = Array.from(agents.values()); | ||
| 655 | if (roster.length > ROSTER_LIMIT) { | ||
| 656 | // Prefer live, then waiting/idle, then newest settled. | ||
| 657 | const rank = (agent: MutableAgent): number => | ||
| 658 | isActiveSubagentStatus(agent.status) ? 0 : agent.status === "idle" ? 1 : 2; | ||
| 659 | roster = roster | ||
| 660 | .slice() | ||
| 661 | .sort((a, b) => rank(a) - rank(b) || b.updatedAt.localeCompare(a.updatedAt)) | ||
| 662 | .slice(0, ROSTER_LIMIT); | ||
| 663 | } | ||
| 664 | |||
| 665 | return roster.map((agent) => ({ ...agent })); | ||
| 666 | } | ||
| 667 | |||
| 668 | export interface AgentPanelWorkflowGroup { | ||
| 669 | readonly workflow: RuntimeSubagent; | ||
| 670 | readonly phases: ReadonlyArray<{ | ||
| 671 | readonly index: number; | ||
| 672 | readonly title: string; | ||
| 673 | readonly members: ReadonlyArray<RuntimeSubagent>; | ||
| 674 | /** done = every member settled (success or error); running = any active. */ | ||
| 675 | readonly state: "pending" | "running" | "done"; | ||
| 676 | readonly activeCount: number; | ||
| 677 | readonly settledCount: number; | ||
| 678 | }>; | ||
| 679 | /** Members with no resolvable phase (orphans render under the workflow). */ | ||
| 680 | readonly unphasedMembers: ReadonlyArray<RuntimeSubagent>; | ||
| 681 | } | ||
| 682 | |||
| 683 | export interface AgentPanelModel { | ||
| 684 | readonly workflows: ReadonlyArray<AgentPanelWorkflowGroup>; | ||
| 685 | readonly directAgents: ReadonlyArray<RuntimeSubagent>; | ||
| 686 | readonly runningCount: number; | ||
| 687 | readonly waitingCount: number; | ||
| 688 | readonly idleCount: number; | ||
| 689 | readonly settledCount: number; | ||
| 690 | readonly totalTokens: number; | ||
| 691 | readonly hasAgents: boolean; | ||
| 692 | readonly liveCount: number; | ||
| 693 | } | ||
| 694 | |||
| 695 | const EMPTY_PANEL_MODEL: AgentPanelModel = { | ||
| 696 | workflows: [], | ||
| 697 | directAgents: [], | ||
| 698 | runningCount: 0, | ||
| 699 | waitingCount: 0, | ||
| 700 | idleCount: 0, | ||
| 701 | settledCount: 0, | ||
| 702 | totalTokens: 0, | ||
| 703 | hasAgents: false, | ||
| 704 | liveCount: 0, | ||
| 705 | }; | ||
| 706 | |||
| 707 | export function emptyAgentPanelModel(): AgentPanelModel { | ||
| 708 | return EMPTY_PANEL_MODEL; | ||
| 709 | } | ||
| 710 | |||
| 711 | /** | ||
| 712 | * Source-neutral view model. When the orchestration-v2 subagent projection | ||
| 713 | * exists for the thread, pass it as v2Projection and it wins outright — the | ||
| 714 | * two sources are never merged (duplicate-agents failure mode). Until v2 | ||
| 715 | * lands, callers pass null and the native fold output is used. | ||
| 716 | */ | ||
| 717 | export function deriveAgentPanelModel({ | ||
| 718 | agents, | ||
| 719 | v2Projection, | ||
| 720 | }: { | ||
| 721 | readonly agents: ReadonlyArray<RuntimeSubagent>; | ||
| 722 | readonly v2Projection?: ReadonlyArray<RuntimeSubagent> | null; | ||
| 723 | }): AgentPanelModel { | ||
| 724 | const source = v2Projection ?? agents; | ||
| 725 | if (source.length === 0) { | ||
| 726 | return EMPTY_PANEL_MODEL; | ||
| 727 | } | ||
| 728 | |||
| 729 | const workflows = source.filter((agent) => agent.kind === "workflow"); | ||
| 730 | const workflowIds = new Set(workflows.map((workflow) => workflow.id)); | ||
| 731 | const members = new Map<string, RuntimeSubagent[]>(); | ||
| 732 | const direct: RuntimeSubagent[] = []; | ||
| 733 | |||
| 734 | for (const agent of source) { | ||
| 735 | if (agent.kind === "workflow") { | ||
| 736 | continue; | ||
| 737 | } | ||
| 738 | if (agent.parentAgentId !== null && workflowIds.has(agent.parentAgentId)) { | ||
| 739 | const list = members.get(agent.parentAgentId) ?? []; | ||
| 740 | list.push(agent); | ||
| 741 | members.set(agent.parentAgentId, list); | ||
| 742 | } else { | ||
| 743 | // Orphaned members (coordinator aged out) fall back to the direct list. | ||
| 744 | direct.push(agent); | ||
| 745 | } | ||
| 746 | } | ||
| 747 | |||
| 748 | const workflowGroups: AgentPanelWorkflowGroup[] = workflows.map((workflow) => { | ||
| 749 | const workflowMembers = members.get(workflow.id) ?? []; | ||
| 750 | const knownPhases = | ||
| 751 | workflow.phases.length > 0 | ||
| 752 | ? workflow.phases | ||
| 753 | : (() => { | ||
| 754 | const derived = new Map<number, string>(); | ||
| 755 | for (const member of workflowMembers) { | ||
| 756 | if (member.phaseIndex !== null && !derived.has(member.phaseIndex)) { | ||
| 757 | derived.set( | ||
| 758 | member.phaseIndex, | ||
| 759 | member.phaseTitle ?? `Phase ${member.phaseIndex + 1}`, | ||
| 760 | ); | ||
| 761 | } | ||
| 762 | } | ||
| 763 | return Array.from(derived.entries()) | ||
| 764 | .map(([index, title]) => ({ index, title })) | ||
| 765 | .slice() | ||
| 766 | .sort((a, b) => a.index - b.index); | ||
| 767 | })(); | ||
| 768 | |||
| 769 | const knownPhaseIndices = new Set(knownPhases.map((phase) => phase.index)); | ||
| 770 | const phases = knownPhases.map((phase) => { | ||
| 771 | const phaseMembers = workflowMembers | ||
| 772 | .filter((member) => member.phaseIndex === phase.index) | ||
| 773 | .slice() | ||
| 774 | .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0)); | ||
| 775 | const activeCount = phaseMembers.filter( | ||
| 776 | // Idle members count as active for phase-liveness: a resumable Codex | ||
| 777 | // member has not finished the phase. | ||
| 778 | (member) => isActiveSubagentStatus(member.status) || member.status === "idle", | ||
| 779 | ).length; | ||
| 780 | const settledCount = phaseMembers.filter((member) => | ||
| 781 | isTerminalSubagentStatus(member.status), | ||
| 782 | ).length; | ||
| 783 | const state: "pending" | "running" | "done" = | ||
| 784 | phaseMembers.length === 0 | ||
| 785 | ? "pending" | ||
| 786 | : activeCount > 0 | ||
| 787 | ? "running" | ||
| 788 | : settledCount === phaseMembers.length | ||
| 789 | ? "done" | ||
| 790 | : "pending"; | ||
| 791 | return { | ||
| 792 | index: phase.index, | ||
| 793 | title: phase.title, | ||
| 794 | members: phaseMembers, | ||
| 795 | state, | ||
| 796 | activeCount, | ||
| 797 | settledCount, | ||
| 798 | }; | ||
| 799 | }); | ||
| 800 | |||
| 801 | // Unknown phase indices land here too — a member must never vanish just | ||
| 802 | // because its phase row was lost (review finding). | ||
| 803 | const unphasedMembers = workflowMembers | ||
| 804 | .filter((member) => member.phaseIndex === null || !knownPhaseIndices.has(member.phaseIndex)) | ||
| 805 | .slice() | ||
| 806 | .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0)); | ||
| 807 | |||
| 808 | return { workflow, phases, unphasedMembers }; | ||
| 809 | }); | ||
| 810 | |||
| 811 | let runningCount = 0; | ||
| 812 | let waitingCount = 0; | ||
| 813 | let idleCount = 0; | ||
| 814 | let settledCount = 0; | ||
| 815 | let totalTokens = 0; | ||
| 816 | for (const agent of source) { | ||
| 817 | if (agent.status === "running" || agent.status === "pending") runningCount += 1; | ||
| 818 | else if (agent.status === "waiting") waitingCount += 1; | ||
| 819 | else if (agent.status === "idle") idleCount += 1; | ||
| 820 | else settledCount += 1; | ||
| 821 | // Workflow coordinators aggregate member usage upstream in some providers; | ||
| 822 | // avoid double counting by only summing leaf agents when members exist. | ||
| 823 | if (agent.kind !== "workflow" || (members.get(agent.id) ?? []).length === 0) { | ||
| 824 | totalTokens += agent.usage?.totalTokens ?? 0; | ||
| 825 | } | ||
| 826 | } | ||
| 827 | |||
| 828 | return { | ||
| 829 | workflows: workflowGroups, | ||
| 830 | directAgents: direct.slice().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), | ||
| 831 | runningCount, | ||
| 832 | waitingCount, | ||
| 833 | idleCount, | ||
| 834 | settledCount, | ||
| 835 | totalTokens, | ||
| 836 | hasAgents: true, | ||
| 837 | liveCount: runningCount + waitingCount, | ||
| 838 | }; | ||
| 839 | } | ||
| 840 | |||
| 841 | /** | ||
| 842 | * Members ordered by urgency for the capped inline workflow card: running and | ||
| 843 | * failed first, then waiting, then most recently updated. | ||
| 844 | */ | ||
| 845 | export function workflowCardMembers( | ||
| 846 | group: AgentPanelWorkflowGroup, | ||
| 847 | limit: number, | ||
| 848 | ): { readonly visible: ReadonlyArray<RuntimeSubagent>; readonly overflow: number } { | ||
| 849 | const all = [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; | ||
| 850 | const urgency = (agent: RuntimeSubagent): number => { | ||
| 851 | if (agent.status === "failed") return 0; | ||
| 852 | if (agent.status === "running") return 1; | ||
| 853 | if (agent.status === "waiting") return 2; | ||
| 854 | return 3; | ||
| 855 | }; | ||
| 856 | const ordered = all | ||
| 857 | .slice() | ||
| 858 | .sort((a, b) => urgency(a) - urgency(b) || b.updatedAt.localeCompare(a.updatedAt)); | ||
| 859 | return { | ||
| 860 | visible: ordered.slice(0, limit), | ||
| 861 | overflow: Math.max(0, ordered.length - limit), | ||
| 862 | }; | ||
| 863 | } | ||
| 864 | |||
| 865 | /** Kinds the timeline should not render as generic rows (fold input only). */ | ||
| 866 | export function isSubagentActivityKind(kind: string): boolean { | ||
| 867 | return ( | ||
| 868 | kind === "task.started" || | ||
| 869 | kind === "task.progress" || | ||
| 870 | kind === "task.updated" || | ||
| 871 | kind === "task.completed" || | ||
| 872 | kind === "tool.progress" | ||
| 873 | ); | ||
| 874 | } | ||
| 875 | |||
| 876 | /** | ||
| 877 | * Quiet-timeline guarantee: tool rows attributed to an owning agent belong in | ||
| 878 | * the Agents surface, not the parent chat. Unattributed rows must stay. | ||
| 879 | */ | ||
| 880 | export function isAgentAttributedToolActivity(activity: OrchestrationThreadActivity): boolean { | ||
| 881 | if (typeof activity.payload !== "object" || activity.payload === null) { | ||
| 882 | return false; | ||
| 883 | } | ||
| 884 | const payload = activity.payload as Record<string, unknown>; | ||
| 885 | return typeof payload.agentId === "string" && payload.agentId.trim().length > 0; | ||
| 886 | } | ||
| 887 | |||
| 888 | /** Timeline-bypassing synthesized rows (Codex children, workflow members). */ | ||
| 889 | export function isTimelineBypassActivity(activity: OrchestrationThreadActivity): boolean { | ||
| 890 | if (typeof activity.payload !== "object" || activity.payload === null) { | ||
| 891 | return false; | ||
| 892 | } | ||
| 893 | return (activity.payload as Record<string, unknown>).timelineBypass === true; | ||
| 894 | } | ||
| 895 | |||
| 896 | /** | ||
| 897 | * Compact model chip text: strips vendor prefixes/date-or-context suffixes | ||
| 898 | * ("claude-sonnet-5[1m]" → "sonnet-5[1m]", "claude-opus-4-20250514" → | ||
| 899 | * "opus-4"). Unknown ids pass through untouched; effort appends as "· high". | ||
| 900 | */ | ||
| 901 | export function formatSubagentModelLabel( | ||
| 902 | model: string | null, | ||
| 903 | effort: string | null, | ||
| 904 | ): string | null { | ||
| 905 | if (!model) { | ||
| 906 | return null; | ||
| 907 | } | ||
| 908 | const compact = model | ||
| 909 | .replace(/^claude-/, "") | ||
| 910 | .replace(/-\d{8}$/, "") | ||
| 911 | .replace(/-latest$/, ""); | ||
| 912 | return effort ? `${compact} · ${effort}` : compact; | ||
| 913 | } | ||
| 914 | |||
| 915 | export function formatSubagentTokenCount(totalTokens: number): string { | ||
| 916 | if (totalTokens < 1000) { | ||
| 917 | return `${totalTokens}`; | ||
| 918 | } | ||
| 919 | if (totalTokens < 1_000_000) { | ||
| 920 | const value = totalTokens / 1000; | ||
| 921 | return `${value >= 100 ? Math.round(value) : value.toFixed(1)}k`; | ||
| 922 | } | ||
| 923 | return `${(totalTokens / 1_000_000).toFixed(1)}M`; | ||
| 924 | } | ||
packages/contracts/src/orchestration.ts+59| ... | @@ -24,6 +24,7 @@ import { ProviderInstanceId } from "./providerInstance.ts"; | ... | @@ -24,6 +24,7 @@ import { ProviderInstanceId } from "./providerInstance.ts"; |
| 24 | 24 | ||
| 25 | export const ORCHESTRATION_WS_METHODS = { | 25 | export const ORCHESTRATION_WS_METHODS = { |
| 26 | dispatchCommand: "orchestration.dispatchCommand", | 26 | dispatchCommand: "orchestration.dispatchCommand", |
| 27 | getWorkflowScript: "orchestration.getWorkflowScript", | ||
| 27 | getTurnDiff: "orchestration.getTurnDiff", | 28 | getTurnDiff: "orchestration.getTurnDiff", |
| 28 | getFullThreadDiff: "orchestration.getFullThreadDiff", | 29 | getFullThreadDiff: "orchestration.getFullThreadDiff", |
| 29 | searchThreads: "orchestration.searchThreads", | 30 | searchThreads: "orchestration.searchThreads", |
| ... | @@ -438,6 +439,12 @@ export const OrchestrationThreadShell = Schema.Struct({ | ... | @@ -438,6 +439,12 @@ export const OrchestrationThreadShell = Schema.Struct({ |
| 438 | hasPendingApprovals: Schema.Boolean, | 439 | hasPendingApprovals: Schema.Boolean, |
| 439 | hasPendingUserInput: Schema.Boolean, | 440 | hasPendingUserInput: Schema.Boolean, |
| 440 | hasActionableProposedPlan: Schema.Boolean, | 441 | hasActionableProposedPlan: Schema.Boolean, |
| 442 | /** | ||
| 443 | * Native background work alive after the turn settles: "working" while | ||
| 444 | * subagents/workflows run, "monitoring" when watch loops are the only | ||
| 445 | * live work. Optional so old servers/clients interop; absent = none. | ||
| 446 | */ | ||
| 447 | backgroundLiveness: Schema.optional(Schema.NullOr(Schema.Literals(["working", "monitoring"]))), | ||
| 441 | }); | 448 | }); |
| 442 | export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; | 449 | export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; |
| 443 | 450 | ||
| ... | @@ -1464,11 +1471,63 @@ export const OrchestrationSearchThreadsResult = Schema.Struct({ | ... | @@ -1464,11 +1471,63 @@ export const OrchestrationSearchThreadsResult = Schema.Struct({ |
| 1464 | }); | 1471 | }); |
| 1465 | export type OrchestrationSearchThreadsResult = typeof OrchestrationSearchThreadsResult.Type; | 1472 | export type OrchestrationSearchThreadsResult = typeof OrchestrationSearchThreadsResult.Type; |
| 1466 | 1473 | ||
| 1474 | export const OrchestrationGetWorkflowScriptInput = Schema.Struct({ | ||
| 1475 | threadId: ThreadId, | ||
| 1476 | /** Absolute path from the workflow's runHandles.scriptPath. The server | ||
| 1477 | * re-derives containment; the client value is a hint, never trusted. */ | ||
| 1478 | scriptPath: TrimmedNonEmptyString, | ||
| 1479 | }); | ||
| 1480 | export type OrchestrationGetWorkflowScriptInput = typeof OrchestrationGetWorkflowScriptInput.Type; | ||
| 1481 | |||
| 1482 | export const OrchestrationGetWorkflowScriptResult = Schema.Struct({ | ||
| 1483 | scriptPath: TrimmedNonEmptyString, | ||
| 1484 | contents: Schema.String, | ||
| 1485 | truncated: Schema.Boolean, | ||
| 1486 | }); | ||
| 1487 | export type OrchestrationGetWorkflowScriptResult = typeof OrchestrationGetWorkflowScriptResult.Type; | ||
| 1488 | |||
| 1489 | const WORKFLOW_SCRIPT_ERROR_MESSAGES = { | ||
| 1490 | "invalid-path": "Workflow scripts must be absolute .js paths.", | ||
| 1491 | "root-unavailable": "Script root unavailable.", | ||
| 1492 | "not-found": "Script not found.", | ||
| 1493 | "outside-root": "Script path is outside the workflow scripts root.", | ||
| 1494 | "not-js": "Resolved script is not a .js file.", | ||
| 1495 | "not-regular-file": "Script is not a regular file.", | ||
| 1496 | "changed-during-read": "Script changed between resolution and open.", | ||
| 1497 | "read-failed": "Script read failed.", | ||
| 1498 | } as const; | ||
| 1499 | |||
| 1500 | export class OrchestrationGetWorkflowScriptError extends Schema.TaggedErrorClass<OrchestrationGetWorkflowScriptError>()( | ||
| 1501 | "OrchestrationGetWorkflowScriptError", | ||
| 1502 | { | ||
| 1503 | reason: Schema.Literals([ | ||
| 1504 | "invalid-path", | ||
| 1505 | "root-unavailable", | ||
| 1506 | "not-found", | ||
| 1507 | "outside-root", | ||
| 1508 | "not-js", | ||
| 1509 | "not-regular-file", | ||
| 1510 | "changed-during-read", | ||
| 1511 | "read-failed", | ||
| 1512 | ]), | ||
| 1513 | scriptPath: Schema.String, | ||
| 1514 | cause: Schema.optional(Schema.Defect()), | ||
| 1515 | }, | ||
| 1516 | ) { | ||
| 1517 | override get message(): string { | ||
| 1518 | return WORKFLOW_SCRIPT_ERROR_MESSAGES[this.reason]; | ||
| 1519 | } | ||
| 1520 | } | ||
| 1521 | |||
| 1467 | export const OrchestrationRpcSchemas = { | 1522 | export const OrchestrationRpcSchemas = { |
| 1468 | dispatchCommand: { | 1523 | dispatchCommand: { |
| 1469 | input: ClientOrchestrationCommand, | 1524 | input: ClientOrchestrationCommand, |
| 1470 | output: DispatchResult, | 1525 | output: DispatchResult, |
| 1471 | }, | 1526 | }, |
| 1527 | getWorkflowScript: { | ||
| 1528 | input: OrchestrationGetWorkflowScriptInput, | ||
| 1529 | output: OrchestrationGetWorkflowScriptResult, | ||
| 1530 | }, | ||
| 1472 | getTurnDiff: { | 1531 | getTurnDiff: { |
| 1473 | input: OrchestrationGetTurnDiffInput, | 1532 | input: OrchestrationGetTurnDiffInput, |
| 1474 | output: OrchestrationGetTurnDiffResult, | 1533 | output: OrchestrationGetTurnDiffResult, |
packages/contracts/src/providerRuntime.test.ts+20-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | ||
| 4 | import { ProviderRuntimeEvent } from "./providerRuntime.ts"; | 4 | import { classifyTaskAgentKind, ProviderRuntimeEvent } from "./providerRuntime.ts"; |
| 5 | 5 | ||
| 6 | const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent); | 6 | const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent); |
| 7 | 7 | ||
| ... | @@ -182,3 +182,22 @@ describe("ProviderRuntimeEvent", () => { | ... | @@ -182,3 +182,22 @@ describe("ProviderRuntimeEvent", () => { |
| 182 | expect(parsed.payload.usage.usedTokens).toBe(31251); | 182 | expect(parsed.payload.usage.usedTokens).toBe(31251); |
| 183 | }); | 183 | }); |
| 184 | }); | 184 | }); |
| 185 | |||
| 186 | describe("classifyTaskAgentKind", () => { | ||
| 187 | it("classifies agent-flavored, watch-loop, and inert types", () => { | ||
| 188 | expect(classifyTaskAgentKind({ taskType: "local_agent" })).toBe("agent"); | ||
| 189 | expect(classifyTaskAgentKind({ taskType: "local_workflow" })).toBe("agent"); | ||
| 190 | expect(classifyTaskAgentKind({ taskType: undefined })).toBe("agent"); | ||
| 191 | expect(classifyTaskAgentKind({ taskType: "brand_new_agent_type" })).toBe("agent"); | ||
| 192 | expect(classifyTaskAgentKind({ taskType: "local_bash" })).toBe("background"); | ||
| 193 | expect(classifyTaskAgentKind({ taskType: "monitor" })).toBe("background"); | ||
| 194 | expect(classifyTaskAgentKind({ taskType: "plan" })).toBe("background"); | ||
| 195 | }); | ||
| 196 | |||
| 197 | it("agent-owned tasks are background unless themselves agent-flavored", () => { | ||
| 198 | expect(classifyTaskAgentKind({ taskType: "local_bash", agentId: "owner" })).toBe("background"); | ||
| 199 | expect(classifyTaskAgentKind({ taskType: undefined, agentId: "owner" })).toBe("background"); | ||
| 200 | // Nested agent: outlives its parent, stays in the roster. | ||
| 201 | expect(classifyTaskAgentKind({ taskType: "local_agent", agentId: "owner" })).toBe("agent"); | ||
| 202 | }); | ||
| 203 | }); |
packages/contracts/src/providerRuntime.ts+174-1| ... | @@ -176,6 +176,7 @@ const ProviderRuntimeEventType = Schema.Literals([ | ... | @@ -176,6 +176,7 @@ const ProviderRuntimeEventType = Schema.Literals([ |
| 176 | "user-input.resolved", | 176 | "user-input.resolved", |
| 177 | "task.started", | 177 | "task.started", |
| 178 | "task.progress", | 178 | "task.progress", |
| 179 | "task.updated", | ||
| 179 | "task.completed", | 180 | "task.completed", |
| 180 | "hook.started", | 181 | "hook.started", |
| 181 | "hook.progress", | 182 | "hook.progress", |
| ... | @@ -226,6 +227,7 @@ const UserInputRequestedType = Schema.Literal("user-input.requested"); | ... | @@ -226,6 +227,7 @@ const UserInputRequestedType = Schema.Literal("user-input.requested"); |
| 226 | const UserInputResolvedType = Schema.Literal("user-input.resolved"); | 227 | const UserInputResolvedType = Schema.Literal("user-input.resolved"); |
| 227 | const TaskStartedType = Schema.Literal("task.started"); | 228 | const TaskStartedType = Schema.Literal("task.started"); |
| 228 | const TaskProgressType = Schema.Literal("task.progress"); | 229 | const TaskProgressType = Schema.Literal("task.progress"); |
| 230 | const TaskUpdatedType = Schema.Literal("task.updated"); | ||
| 229 | const TaskCompletedType = Schema.Literal("task.completed"); | 231 | const TaskCompletedType = Schema.Literal("task.completed"); |
| 230 | const HookStartedType = Schema.Literal("hook.started"); | 232 | const HookStartedType = Schema.Literal("hook.started"); |
| 231 | const HookProgressType = Schema.Literal("hook.progress"); | 233 | const HookProgressType = Schema.Literal("hook.progress"); |
| ... | @@ -407,6 +409,13 @@ export const ItemLifecyclePayload = Schema.Struct({ | ... | @@ -407,6 +409,13 @@ export const ItemLifecyclePayload = Schema.Struct({ |
| 407 | title: Schema.optional(TrimmedNonEmptyStringSchema), | 409 | title: Schema.optional(TrimmedNonEmptyStringSchema), |
| 408 | detail: Schema.optional(TrimmedNonEmptyStringSchema), | 410 | detail: Schema.optional(TrimmedNonEmptyStringSchema), |
| 409 | data: Schema.optional(Schema.Unknown), | 411 | data: Schema.optional(Schema.Unknown), |
| 412 | /** | ||
| 413 | * Owning agent when this item ran inside a subagent (resolved from the | ||
| 414 | * SDK's parent_tool_use_id). Clients re-home attributed items out of the | ||
| 415 | * main timeline and into the owning agent's Agents-surface row. | ||
| 416 | */ | ||
| 417 | agentId: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 418 | parentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 410 | }); | 419 | }); |
| 411 | export type ItemLifecyclePayload = typeof ItemLifecyclePayload.Type; | 420 | export type ItemLifecyclePayload = typeof ItemLifecyclePayload.Type; |
| 412 | 421 | ||
| ... | @@ -459,27 +468,180 @@ const UserInputResolvedPayload = Schema.Struct({ | ... | @@ -459,27 +468,180 @@ const UserInputResolvedPayload = Schema.Struct({ |
| 459 | }); | 468 | }); |
| 460 | export type UserInputResolvedPayload = typeof UserInputResolvedPayload.Type; | 469 | export type UserInputResolvedPayload = typeof UserInputResolvedPayload.Type; |
| 461 | 470 | ||
| 471 | /** | ||
| 472 | * Typed per-task usage rollup. Field names match the orchestration-v2 subagent | ||
| 473 | * usage vocabulary (#4779) so the eventual migration is a rename, not a remap. | ||
| 474 | * Claude reports per-activation deltas; Codex reports cumulative totals — the | ||
| 475 | * merge strategy is provider-specific and lives in client-runtime. | ||
| 476 | */ | ||
| 477 | export const RuntimeTaskUsage = Schema.Struct({ | ||
| 478 | totalTokens: NonNegativeInt, | ||
| 479 | inputTokens: Schema.optional(NonNegativeInt), | ||
| 480 | cachedInputTokens: Schema.optional(NonNegativeInt), | ||
| 481 | outputTokens: Schema.optional(NonNegativeInt), | ||
| 482 | reasoningOutputTokens: Schema.optional(NonNegativeInt), | ||
| 483 | toolUses: Schema.optional(NonNegativeInt), | ||
| 484 | durationMs: Schema.optional(NonNegativeInt), | ||
| 485 | }); | ||
| 486 | export type RuntimeTaskUsage = typeof RuntimeTaskUsage.Type; | ||
| 487 | |||
| 488 | export const TaskWorkflowPhase = Schema.Struct({ | ||
| 489 | index: NonNegativeInt, | ||
| 490 | title: TrimmedNonEmptyStringSchema, | ||
| 491 | }); | ||
| 492 | export type TaskWorkflowPhase = typeof TaskWorkflowPhase.Type; | ||
| 493 | |||
| 494 | export const TaskRunHandles = Schema.Struct({ | ||
| 495 | runId: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 496 | scriptPath: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 497 | transcriptDir: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 498 | /** Only http/https URLs may be stored here — sanitized at the adapter. */ | ||
| 499 | sessionUrl: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 500 | }); | ||
| 501 | export type TaskRunHandles = typeof TaskRunHandles.Type; | ||
| 502 | |||
| 503 | /** | ||
| 504 | * Watch-loop task types: Monitor-tool tasks plus background shells (a shell | ||
| 505 | * that outlives its turn is in practice a watch loop). Canonical single copy — | ||
| 506 | * the server liveness registry, ingestion's agentKind stamp, and the client | ||
| 507 | * fold's legacy fallback all classify with these sets. | ||
| 508 | */ | ||
| 509 | export const MONITOR_TASK_TYPES: ReadonlySet<string> = new Set([ | ||
| 510 | "monitor", | ||
| 511 | "monitor_mcp", | ||
| 512 | "local_bash", | ||
| 513 | "shell", | ||
| 514 | ]); | ||
| 515 | /** Task types that are neither agents nor watch loops (plan-mode bookkeeping). */ | ||
| 516 | export const INERT_TASK_TYPES: ReadonlySet<string> = new Set(["plan", "dream"]); | ||
| 517 | |||
| 518 | /** | ||
| 519 | * Agent-vs-background classification, stamped by ingestion as `agentKind` so | ||
| 520 | * persisted rows are self-describing. A deliberate denylist: the SDK's | ||
| 521 | * agent-flavored type names drift (subagent, local_agent, local_workflow, …) | ||
| 522 | * and an allowlist silently dropped real subagents when "local_agent" | ||
| 523 | * appeared. A task launched from inside a subagent (agentId set) is | ||
| 524 | * agent-internal background work UNLESS it is itself agent-flavored — a | ||
| 525 | * nested agent can outlive its parent and stays in the roster. | ||
| 526 | */ | ||
| 527 | export function classifyTaskAgentKind(input: { | ||
| 528 | readonly taskType?: string | undefined; | ||
| 529 | readonly agentId?: string | undefined; | ||
| 530 | }): "agent" | "background" { | ||
| 531 | const { taskType, agentId } = input; | ||
| 532 | const nonAgentType = | ||
| 533 | taskType !== undefined && (MONITOR_TASK_TYPES.has(taskType) || INERT_TASK_TYPES.has(taskType)); | ||
| 534 | if (agentId !== undefined && agentId.trim().length > 0) { | ||
| 535 | return taskType === undefined || nonAgentType ? "background" : "agent"; | ||
| 536 | } | ||
| 537 | return nonAgentType ? "background" : "agent"; | ||
| 538 | } | ||
| 539 | |||
| 540 | /** | ||
| 541 | * Optional agent-identity linkage carried on every task lifecycle payload. | ||
| 542 | * Repeated on progress and terminal rows (not just start) so client folds can | ||
| 543 | * reconstruct an agent even when its start row aged out of activity retention. | ||
| 544 | * All fields optional: old emitters and old rows decode unchanged. | ||
| 545 | */ | ||
| 546 | const taskAgentLinkageFields = { | ||
| 547 | /** SDK task_type (subagent/shell/monitor/local_workflow/…), repeated on | ||
| 548 | * every row so folds can classify without the start row. */ | ||
| 549 | taskType: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 550 | /** | ||
| 551 | * Server-stamped classification (classifyTaskAgentKind at ingestion). | ||
| 552 | * Clients trust this stamp outright; rows without it (legacy, pre-stamp) | ||
| 553 | * fall back to client-side heuristics. | ||
| 554 | */ | ||
| 555 | agentKind: Schema.optional(Schema.Literals(["agent", "background"])), | ||
| 556 | /** | ||
| 557 | * Owning agent when the task itself was launched from inside a subagent | ||
| 558 | * (e.g. a subagent's background shell). Clients treat such tasks as | ||
| 559 | * agent-internal and keep them out of the parent work log. | ||
| 560 | */ | ||
| 561 | agentId: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 562 | title: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 563 | role: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 564 | model: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 565 | /** Reasoning effort when known (e.g. "high"). Open string: provider vocabularies differ. */ | ||
| 566 | effort: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 567 | toolUseId: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 568 | parentAgentId: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 569 | workflowName: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 570 | agentIndex: Schema.optional(NonNegativeInt), | ||
| 571 | phaseIndex: Schema.optional(NonNegativeInt), | ||
| 572 | phaseTitle: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 573 | phases: Schema.optional(Schema.Array(TaskWorkflowPhase)), | ||
| 574 | attempt: Schema.optional(NonNegativeInt), | ||
| 575 | runHandles: Schema.optional(TaskRunHandles), | ||
| 576 | outputFile: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 577 | /** Codex agent hierarchy path, e.g. "/root/marlow". */ | ||
| 578 | agentPath: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 579 | /** | ||
| 580 | * Set on provider-synthesized child-agent events (Codex) whose activity | ||
| 581 | * belongs in the Agents surface, never the parent timeline. | ||
| 582 | */ | ||
| 583 | timelineBypass: Schema.optional(Schema.Boolean), | ||
| 584 | } as const; | ||
| 585 | |||
| 586 | export const TaskAgentLinkage = Schema.Struct(taskAgentLinkageFields); | ||
| 587 | export type TaskAgentLinkage = typeof TaskAgentLinkage.Type; | ||
| 588 | |||
| 462 | const TaskStartedPayload = Schema.Struct({ | 589 | const TaskStartedPayload = Schema.Struct({ |
| 463 | taskId: RuntimeTaskId, | 590 | taskId: RuntimeTaskId, |
| 464 | description: Schema.optional(TrimmedNonEmptyStringSchema), | 591 | description: Schema.optional(TrimmedNonEmptyStringSchema), |
| 465 | taskType: Schema.optional(TrimmedNonEmptyStringSchema), | 592 | ...taskAgentLinkageFields, |
| 466 | }); | 593 | }); |
| 467 | export type TaskStartedPayload = typeof TaskStartedPayload.Type; | 594 | export type TaskStartedPayload = typeof TaskStartedPayload.Type; |
| 468 | 595 | ||
| 596 | export const RuntimeTaskStatus = Schema.Literals([ | ||
| 597 | "pending", | ||
| 598 | "running", | ||
| 599 | "waiting", | ||
| 600 | "idle", | ||
| 601 | "completed", | ||
| 602 | "failed", | ||
| 603 | "cancelled", | ||
| 604 | "interrupted", | ||
| 605 | ]); | ||
| 606 | export type RuntimeTaskStatus = typeof RuntimeTaskStatus.Type; | ||
| 607 | |||
| 469 | const TaskProgressPayload = Schema.Struct({ | 608 | const TaskProgressPayload = Schema.Struct({ |
| 470 | taskId: RuntimeTaskId, | 609 | taskId: RuntimeTaskId, |
| 471 | description: TrimmedNonEmptyStringSchema, | 610 | description: TrimmedNonEmptyStringSchema, |
| 472 | summary: Schema.optional(TrimmedNonEmptyStringSchema), | 611 | summary: Schema.optional(TrimmedNonEmptyStringSchema), |
| 473 | usage: Schema.optional(Schema.Unknown), | 612 | usage: Schema.optional(Schema.Unknown), |
| 613 | typedUsage: Schema.optional(RuntimeTaskUsage), | ||
| 474 | lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), | 614 | lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), |
| 615 | /** Present on synthesized member/child progress rows that carry state. */ | ||
| 616 | status: Schema.optional(RuntimeTaskStatus), | ||
| 617 | error: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 618 | ...taskAgentLinkageFields, | ||
| 475 | }); | 619 | }); |
| 476 | export type TaskProgressPayload = typeof TaskProgressPayload.Type; | 620 | export type TaskProgressPayload = typeof TaskProgressPayload.Type; |
| 477 | 621 | ||
| 622 | /** | ||
| 623 | * Non-terminal status patch (from the Claude SDK's task_updated, which main | ||
| 624 | * previously dropped). killed→cancelled and paused→idle are mapped at the | ||
| 625 | * adapter so the wire only carries the shared vocabulary. | ||
| 626 | */ | ||
| 627 | const TaskUpdatedPayload = Schema.Struct({ | ||
| 628 | taskId: RuntimeTaskId, | ||
| 629 | status: Schema.optional(RuntimeTaskStatus), | ||
| 630 | description: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 631 | error: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 632 | endedAt: Schema.optional(IsoDateTime), | ||
| 633 | isBackgrounded: Schema.optional(Schema.Boolean), | ||
| 634 | ...taskAgentLinkageFields, | ||
| 635 | }); | ||
| 636 | export type TaskUpdatedPayload = typeof TaskUpdatedPayload.Type; | ||
| 637 | |||
| 478 | const TaskCompletedPayload = Schema.Struct({ | 638 | const TaskCompletedPayload = Schema.Struct({ |
| 479 | taskId: RuntimeTaskId, | 639 | taskId: RuntimeTaskId, |
| 480 | status: Schema.Literals(["completed", "failed", "stopped"]), | 640 | status: Schema.Literals(["completed", "failed", "stopped"]), |
| 481 | summary: Schema.optional(TrimmedNonEmptyStringSchema), | 641 | summary: Schema.optional(TrimmedNonEmptyStringSchema), |
| 482 | usage: Schema.optional(Schema.Unknown), | 642 | usage: Schema.optional(Schema.Unknown), |
| 643 | typedUsage: Schema.optional(RuntimeTaskUsage), | ||
| 644 | ...taskAgentLinkageFields, | ||
| 483 | }); | 645 | }); |
| 484 | export type TaskCompletedPayload = typeof TaskCompletedPayload.Type; | 646 | export type TaskCompletedPayload = typeof TaskCompletedPayload.Type; |
| 485 | 647 | ||
| ... | @@ -513,6 +675,9 @@ const ToolProgressPayload = Schema.Struct({ | ... | @@ -513,6 +675,9 @@ const ToolProgressPayload = Schema.Struct({ |
| 513 | toolName: Schema.optional(TrimmedNonEmptyStringSchema), | 675 | toolName: Schema.optional(TrimmedNonEmptyStringSchema), |
| 514 | summary: Schema.optional(TrimmedNonEmptyStringSchema), | 676 | summary: Schema.optional(TrimmedNonEmptyStringSchema), |
| 515 | elapsedSeconds: Schema.optional(Schema.Number), | 677 | elapsedSeconds: Schema.optional(Schema.Number), |
| 678 | /** Owning task/agent when the tool ran inside a subagent. */ | ||
| 679 | taskId: Schema.optional(RuntimeTaskId), | ||
| 680 | parentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 516 | }); | 681 | }); |
| 517 | export type ToolProgressPayload = typeof ToolProgressPayload.Type; | 682 | export type ToolProgressPayload = typeof ToolProgressPayload.Type; |
| 518 | 683 | ||
| ... | @@ -835,6 +1000,13 @@ const ProviderRuntimeTaskProgressEvent = Schema.Struct({ | ... | @@ -835,6 +1000,13 @@ const ProviderRuntimeTaskProgressEvent = Schema.Struct({ |
| 835 | }); | 1000 | }); |
| 836 | export type ProviderRuntimeTaskProgressEvent = typeof ProviderRuntimeTaskProgressEvent.Type; | 1001 | export type ProviderRuntimeTaskProgressEvent = typeof ProviderRuntimeTaskProgressEvent.Type; |
| 837 | 1002 | ||
| 1003 | const ProviderRuntimeTaskUpdatedEvent = Schema.Struct({ | ||
| 1004 | ...ProviderRuntimeEventBase.fields, | ||
| 1005 | type: TaskUpdatedType, | ||
| 1006 | payload: TaskUpdatedPayload, | ||
| 1007 | }); | ||
| 1008 | export type ProviderRuntimeTaskUpdatedEvent = typeof ProviderRuntimeTaskUpdatedEvent.Type; | ||
| 1009 | |||
| 838 | const ProviderRuntimeTaskCompletedEvent = Schema.Struct({ | 1010 | const ProviderRuntimeTaskCompletedEvent = Schema.Struct({ |
| 839 | ...ProviderRuntimeEventBase.fields, | 1011 | ...ProviderRuntimeEventBase.fields, |
| 840 | type: TaskCompletedType, | 1012 | type: TaskCompletedType, |
| ... | @@ -995,6 +1167,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ | ... | @@ -995,6 +1167,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ |
| 995 | ProviderRuntimeUserInputResolvedEvent, | 1167 | ProviderRuntimeUserInputResolvedEvent, |
| 996 | ProviderRuntimeTaskStartedEvent, | 1168 | ProviderRuntimeTaskStartedEvent, |
| 997 | ProviderRuntimeTaskProgressEvent, | 1169 | ProviderRuntimeTaskProgressEvent, |
| 1170 | ProviderRuntimeTaskUpdatedEvent, | ||
| 998 | ProviderRuntimeTaskCompletedEvent, | 1171 | ProviderRuntimeTaskCompletedEvent, |
| 999 | ProviderRuntimeHookStartedEvent, | 1172 | ProviderRuntimeHookStartedEvent, |
| 1000 | ProviderRuntimeHookProgressEvent, | 1173 | ProviderRuntimeHookProgressEvent, |
packages/contracts/src/rpc.ts+11| ... | @@ -64,6 +64,7 @@ import { | ... | @@ -64,6 +64,7 @@ import { |
| 64 | OrchestrationGetTurnDiffError, | 64 | OrchestrationGetTurnDiffError, |
| 65 | OrchestrationGetTurnDiffInput, | 65 | OrchestrationGetTurnDiffInput, |
| 66 | OrchestrationRpcSchemas, | 66 | OrchestrationRpcSchemas, |
| 67 | OrchestrationGetWorkflowScriptError, | ||
| 67 | } from "./orchestration.ts"; | 68 | } from "./orchestration.ts"; |
| 68 | import { ProviderInstanceId } from "./providerInstance.ts"; | 69 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 69 | import { | 70 | import { |
| ... | @@ -696,6 +697,15 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make( | ... | @@ -696,6 +697,15 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make( |
| 696 | }, | 697 | }, |
| 697 | ); | 698 | ); |
| 698 | 699 | ||
| 700 | export const WsOrchestrationGetWorkflowScriptRpc = Rpc.make( | ||
| 701 | ORCHESTRATION_WS_METHODS.getWorkflowScript, | ||
| 702 | { | ||
| 703 | payload: OrchestrationRpcSchemas.getWorkflowScript.input, | ||
| 704 | success: OrchestrationRpcSchemas.getWorkflowScript.output, | ||
| 705 | error: Schema.Union([OrchestrationGetWorkflowScriptError, EnvironmentAuthorizationError]), | ||
| 706 | }, | ||
| 707 | ); | ||
| 708 | |||
| 699 | export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, { | 709 | export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, { |
| 700 | payload: OrchestrationGetTurnDiffInput, | 710 | payload: OrchestrationGetTurnDiffInput, |
| 701 | success: OrchestrationRpcSchemas.getTurnDiff.output, | 711 | success: OrchestrationRpcSchemas.getTurnDiff.output, |
| ... | @@ -867,6 +877,7 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -867,6 +877,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 867 | WsSubscribeBackgroundPolicyRpc, | 877 | WsSubscribeBackgroundPolicyRpc, |
| 868 | WsSubscribeResourceTelemetryRpc, | 878 | WsSubscribeResourceTelemetryRpc, |
| 869 | WsOrchestrationDispatchCommandRpc, | 879 | WsOrchestrationDispatchCommandRpc, |
| 880 | WsOrchestrationGetWorkflowScriptRpc, | ||
| 870 | WsOrchestrationGetTurnDiffRpc, | 881 | WsOrchestrationGetTurnDiffRpc, |
| 871 | WsOrchestrationGetFullThreadDiffRpc, | 882 | WsOrchestrationGetFullThreadDiffRpc, |
| 872 | WsOrchestrationSearchThreadsRpc, | 883 | WsOrchestrationSearchThreadsRpc, |