From d3745113054afb151c803b05169b11c13dc80b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 18 Jun 2026 13:08:19 +0100 Subject: [PATCH 01/15] Port subagent threading customization --- SUBAGENTS.md | 139 ++++++++ .../OrchestrationEngineHarness.integration.ts | 9 +- .../Layers/ProjectionPipeline.ts | 88 +++++ .../Layers/ProjectionSnapshotQuery.test.ts | 8 + .../Layers/ProjectionSnapshotQuery.ts | 93 +++++ .../Layers/ProviderCommandReactor.test.ts | 82 +++++ .../Layers/ProviderCommandReactor.ts | 29 +- .../Layers/ProviderRuntimeIngestion.test.ts | 266 +++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 334 +++++++++++++++++- apps/server/src/orchestration/decider.ts | 33 ++ apps/server/src/orchestration/projector.ts | 6 + .../Layers/ProjectionRepositories.test.ts | 12 + .../persistence/Layers/ProjectionThreads.ts | 108 ++++++ apps/server/src/persistence/Migrations.ts | 4 + .../033_ProjectionThreadParentRelation.ts | 35 ++ ...ckfillEmptyProjectionThreadRootIds.test.ts | 94 +++++ ...34_BackfillEmptyProjectionThreadRootIds.ts | 12 + .../persistence/Services/ProjectionThreads.ts | 15 + .../src/provider/Layers/CodexAdapter.test.ts | 154 ++++++++ .../src/provider/Layers/CodexAdapter.ts | 224 +++++++++++- .../provider/Layers/CodexSessionRuntime.ts | 191 ++++++++-- apps/server/src/server.ts | 2 +- apps/web/src/components/ChatView.tsx | 264 +++++++++----- apps/web/src/components/Sidebar.tsx | 277 ++++++++++++--- apps/web/src/components/chat/ChatHeader.tsx | 49 ++- .../components/chat/MessagesTimeline.test.tsx | 34 ++ .../src/components/chat/MessagesTimeline.tsx | 159 ++++++++- .../runtime/service.coalescing.test.ts | 87 +++++ apps/web/src/environments/runtime/service.ts | 177 +++++++++- apps/web/src/hooks/useThreadActions.ts | 136 ++++--- apps/web/src/session-logic.test.ts | 334 ++++++++++++++++++ apps/web/src/session-logic.ts | 245 ++++++++++++- apps/web/src/store.test.ts | 244 ++++++++++++- apps/web/src/store.ts | 268 +++++++++++++- apps/web/src/subagentDisplay.test.tsx | 33 ++ apps/web/src/subagentDisplay.tsx | 108 ++++++ apps/web/src/types.ts | 4 + .../client-runtime/src/threadDetailReducer.ts | 3 + packages/contracts/src/orchestration.ts | 39 ++ 39 files changed, 4140 insertions(+), 259 deletions(-) create mode 100644 SUBAGENTS.md create mode 100644 apps/server/src/persistence/Migrations/033_ProjectionThreadParentRelation.ts create mode 100644 apps/server/src/persistence/Migrations/034_BackfillEmptyProjectionThreadRootIds.test.ts create mode 100644 apps/server/src/persistence/Migrations/034_BackfillEmptyProjectionThreadRootIds.ts create mode 100644 apps/web/src/environments/runtime/service.coalescing.test.ts create mode 100644 apps/web/src/subagentDisplay.test.tsx create mode 100644 apps/web/src/subagentDisplay.tsx diff --git a/SUBAGENTS.md b/SUBAGENTS.md new file mode 100644 index 00000000000..4e5025769a4 --- /dev/null +++ b/SUBAGENTS.md @@ -0,0 +1,139 @@ +# UI-Aware Subagent Orchestration Plan + +## Status + +This plan has been updated after the implementation and review-fix pass. The feature is implemented for Codex only. Unsupported providers should continue using the previous inline subagent-output behavior until they expose durable child-thread lineage. + +The current behavior is: + +- A Codex subagent is represented as its own conversation thread. +- Active subagent threads appear in the sidebar nested under the direct parent that spawned them. +- Completed, errored, interrupted, or stopped subagent threads are normally hidden from the sidebar but remain reachable from the parent conversation view. When a terminal subagent conversation is the active route, that subagent and any intermediate subagent ancestors are shown in the sidebar at their normal nested positions until the user navigates away. +- A parent conversation view shows only parent-owned output and subagent summary blocks for direct children. +- A child conversation view shows the raw initial prompt that launched that child when Codex exposes it, followed by that child's output, tool calls, diffs, MCP calls, and other actions. Grandchildren appear only as blocks inside their direct parent child view. +- Users cannot prompt or steer a subagent. The child view exposes stop control only while the child is running and a header button for returning to its direct parent conversation. +- Stopping a parent does not automatically stop running children. Stopping a child explicitly targets that child. +- Archive/delete actions are exposed only for root parent conversations and should include descendant subagent threads as part of that root lifecycle. + +## Assessment + +The original architecture had three important gaps: no durable parent/child thread lineage, no routeable hidden child-thread detail after completion, and parent timelines that mixed child output/actions into the parent view. Those gaps have now been addressed for Codex by carrying Codex child-session identity into orchestration metadata, projecting child threads as first-class threads, and rendering child work only in the child's own conversation. + +The most important implementation choice is that a subagent is not just a special visual box. It is a real projected thread with a `parentRelation.kind === "subagent"` relation. The parent timeline keeps only a compact child reference block derived from the Codex collab lifecycle item, while child output and actions are ingested into the child thread's timeline. + +## Implemented Data Model + +Thread parentage is persisted on projected threads. The implemented shape is: + +```ts +type OrchestrationThreadParentRelation = + | { + kind: "root"; + rootThreadId: ThreadId; + } + | { + kind: "subagent"; + rootThreadId: ThreadId; + parentThreadId: ThreadId; + parentTurnId: TurnId | null; + parentItemId: string | null; + parentActivitySequence: number; + providerThreadId: string; + titleSeed: string | null; + depth: number; + startedAt: string; + completedAt: string | null; + status: "running" | "completed" | "errored" | "interrupted" | "stopped"; + }; +``` + +The persistence migration stores this relation as explicit projection-thread columns, including root thread, direct parent thread, provider child thread id, parent activity sequence, title seed, depth, started/completed timestamps, and subagent status. Indexes support parent lookup and root lifecycle lookup. + +Review fixes added preservation guards so a normal root/default projection upsert cannot overwrite an existing subagent relation for the same thread. + +## Server Implementation + +1. Codex child identity is mapped into deterministic local child thread ids from `parentThreadId + providerThreadId`. This intentionally avoids using `parentItemId`, because Codex may emit multiple collab lifecycle/control items for the same child provider thread. + +2. Codex collab lifecycle items now carry `subagentChildren` metadata on the parent activity payload. Each child reference includes the provider thread id, local child thread id, optional parent item id, and optional title seed. The parent UI uses this metadata to render the compact `Subagent - ` block. + +3. Codex collab lifecycle tracking preserves both the raw child prompt and the title seed. The raw prompt comes from the collab tool item `prompt` field and is used as displayable child-thread conversation history; the title seed remains the input for generated child titles and parent summary labeling. Whitespace-only raw prompts are ignored. + +4. Child-thread creation and updates happen through orchestration ingestion. The server preserves the direct parent, root thread id, depth, parent activity sequence, title seed, provider thread id, and started timestamp. Synthetic child shells are created so hidden child routes can be opened before the full projection catches up. + +5. When Codex exposes a raw child prompt, ingestion appends it to the child thread as a non-streaming user message through an internal `thread.message.user.append` command. The prompt message uses stable ids derived from `childThreadId + parentItemId`, is not bound to the parent turn, and is appended even if the child shell already exists because Codex first emitted a started item without a prompt and later emitted a completed item with the concrete prompt. + +6. Child terminal status is derived from child lifecycle events, not from the parent collab item alone. Terminal updates apply only while the relation is still `running`, which prevents later `session.exited` events from overwriting a more specific `completed`, `errored`, `interrupted`, or `stopped` result. + +7. Child stop/interrupt handling routes through the provider-bound root session while targeting the selected child thread/turn. Parent stop remains scoped to the requested parent thread and does not cascade to active children. + +8. Completed child detail remains tied to the root parent lifecycle. The web action layer dispatches archive/delete lifecycle actions for the root and collected descendant subagent threads, with root actions last. Delete also attempts to stop and close terminal state for involved lifecycle thread ids. + +9. Unsupported providers keep the previous fallback behavior. No durable nested-thread behavior should be inferred for Claude, Cursor, OpenCode, or other providers until their event streams expose enough lineage to make child routing reliable. + +## Web Implementation + +1. Sidebar nesting is driven by `parentRelation`. Active subagents render under their direct parent only. Terminal subagents are omitted from the sidebar during normal parent browsing, but the currently open terminal child path remains visible and indented while that child or nested descendant is selected. + +2. Conversation detail routing accepts hidden child threads through projected/synthetic shells. A child thread can be opened from its parent block even after it has disappeared from the active sidebar. + +3. Parent timelines render direct child summary blocks from `subagentChildren`. The block text is `Subagent` while the generated child title is pending or still the placeholder, then `Subagent - <title>` once a generated child title is available. The child title is generated from the child title seed derived from the initial subagent prompt when available, and raw child prompts are not used as the visible title fallback. Duration and status display use shared helpers, with running children described as `Working for <duration>` and completed children described as `Completed in <duration>`. + +4. Parent timelines do not render child prompt messages, child output, child shell commands, child file diffs, child MCP calls, or child action boxes. Those entries appear only inside the child thread view. + +5. Child timelines render their raw launch prompt when available, then their own output/actions, and can render their own direct child summary blocks. This gives arbitrary-depth nesting without showing grandchildren in the original root parent view. + +6. Child conversation views replace the normal composer with a subagent control bar. Users cannot send prompts to a subagent. While a child is running, the available user control is stop. The chat header also includes an up-navigation button that opens the direct parent conversation. + +7. Review fixes removed duplicate compact subagent rows from Codex control sequences such as `wait` and `closeAgent`. Parent timelines now de-dupe child reference rows when all referenced child thread ids were already represented. + +8. Shared subagent display helpers keep duration and fallback labels consistent across parent blocks and child controls. Terminal child rows with missing completion timestamps show an explicit unknown-duration fallback instead of implying successful completion, and active children use `working` wording instead of `running` wording. + +## Decisions Captured + +- Persistence retention: child detail is tied to parent lifecycle. +- Provider scope: Codex only for first implementation; unsupported providers degrade to current behavior. +- Title semantics: use the child title seed from the Codex collab item, normally derived from the subagent's initial prompt. +- Prompt history semantics: use the raw Codex collab item prompt as the child thread's initial user message when available. Do not use the title seed as a fallback prompt, because generated/summarized title seeds are not necessarily the literal child instruction. +- Error semantics: child failures do not bubble up as parent failures. Parent status and child status are independent. +- Missing completion events: follow the same lifecycle behavior as normal agent sessions. Use available terminal events where present; otherwise preserve running/unknown state until a stop, interrupt, session exit, reconnect reconciliation, or later terminal event updates it. +- Stop behavior: stopping a parent does not stop children; stopping a child is allowed from the child view. +- Steering behavior: users cannot manually prompt or steer subagents. +- Diff/checkpoint semantics: child file changes affect the shared workspace and should be parent-visible in aggregate at the workspace level, while per-action rendering remains scoped to the child conversation view. +- Archive/delete semantics: root parent actions own descendant child lifecycle. Child and nested-child rows do not expose independent archive/delete actions. + +## Verification Completed + +The implementation and review fixes have been covered by focused automated tests and Playwright regression checks: + +- Server tests cover Codex subagent ingestion, child terminal status, parent-relation persistence, projection upsert preservation, and child stop/interrupt routing through the provider-bound root session. +- Server tests cover raw subagent prompt projection into child threads, including start-then-complete late prompt updates and whitespace-only prompt suppression. +- Web tests cover sidebar/thread state behavior, duplicate parent subagent control-row removal, child composer suppression, subagent stop control behavior, and duration fallback labels. +- Playwright checked Codex subagent behavior with marker prompts: before the prompt projection fix, the child view showed the output marker but not the initial prompt marker; after the fix, the child view showed the initial prompt marker followed by the output marker. Earlier Playwright coverage also checked that the parent showed exactly one compact subagent block, child output/actions did not leak into the parent, the child view was reachable from the parent block, the child view showed the child command/output, and the child view did not expose a prompt composer. + +Current completion gates for this repo remain: + +```sh +pnpm exec vp check +pnpm exec vp run typecheck +``` + +Use focused package tests for the changed surface. If native mobile code changes in a future pass, also run: + +```sh +pnpm exec vp run lint:mobile +``` + +## Remaining Risks And Hardening Items + +1. Root lifecycle cascade should be hardened server-side for hidden descendants that are not materialized in the current client environment. The client currently dispatches archive/delete for collected descendants, but a database/root-thread cascade would be more robust across reconnects and multi-client gaps. + +2. Diff/checkpoint aggregation is intentionally parent-visible at the workspace level, but the exact UI for aggregate root diffs should be audited separately. Per-action diff rendering is scoped to the child timeline. + +3. Reconnect and restart behavior should be stress-tested with active children, especially when the parent reconnects after receiving child output but before receiving the parent collab lifecycle item. + +4. Multi-client behavior needs broader coverage across web, desktop, VS Code, and mobile shells. The data model is shared, but route guards, sidebar shell subscriptions, and hidden-thread availability should be checked in each client surface. + +5. Deep nesting should be load-tested. The model supports arbitrary depth, but the UI should still be checked for indentation, active-row sorting stability, and large active-child sets. + +6. Unsupported provider fallback should remain explicit. If another provider later exposes durable child-thread lineage, it should be added provider-by-provider rather than by guessing from output text. diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index bc1229811a2..0f7a6700995 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -308,9 +308,14 @@ export const makeOrchestrationIntegrationHarness = ( RuntimeReceiptBusTest, ); const serverSettingsLayer = ServerSettingsService.layerTest(); + const textGenerationLayer = Layer.succeed(TextGeneration, { + generateBranchName: () => Effect.succeed({ branch: "update" }), + generateThreadTitle: () => Effect.succeed({ title: "New thread" }), + } as unknown as TextGenerationShape); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(serverSettingsLayer), + Layer.provideMerge(textGenerationLayer), ); const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ renameBranch: (input: { @@ -319,10 +324,6 @@ export const makeOrchestrationIntegrationHarness = ( readonly newBranch: string; }) => Effect.succeed({ branch: input.newBranch }), }); - const textGenerationLayer = Layer.succeed(TextGeneration, { - generateBranchName: () => Effect.succeed({ branch: "update" }), - generateThreadTitle: () => Effect.succeed({ title: "New thread" }), - } as unknown as TextGenerationShape); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitWorkflowLayer), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..3148c52bd94 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -603,6 +603,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + parentKind: event.payload.parentRelation?.kind ?? "root", + rootThreadId: event.payload.parentRelation?.rootThreadId ?? event.payload.threadId, + parentThreadId: + event.payload.parentRelation?.kind === "subagent" + ? event.payload.parentRelation.parentThreadId + : null, + parentTurnId: + event.payload.parentRelation?.kind === "subagent" + ? event.payload.parentRelation.parentTurnId + : null, + parentItemId: + event.payload.parentRelation?.kind === "subagent" + ? event.payload.parentRelation.parentItemId + : null, + parentActivitySequence: + event.payload.parentRelation?.kind === "subagent" + ? event.payload.parentRelation.parentActivitySequence + : 0, + providerThreadId: + event.payload.parentRelation?.kind === "subagent" + ? event.payload.parentRelation.providerThreadId + : null, + titleSeed: + event.payload.parentRelation?.kind === "subagent" + ? event.payload.parentRelation.titleSeed + : null, + subagentDepth: + event.payload.parentRelation?.kind === "subagent" + ? event.payload.parentRelation.depth + : 0, + subagentStartedAt: + event.payload.parentRelation?.kind === "subagent" + ? event.payload.parentRelation.startedAt + : null, + subagentCompletedAt: + event.payload.parentRelation?.kind === "subagent" + ? event.payload.parentRelation.completedAt + : null, + subagentStatus: + event.payload.parentRelation?.kind === "subagent" + ? event.payload.parentRelation.status + : null, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -662,6 +704,52 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.parentRelation !== undefined + ? { + parentKind: event.payload.parentRelation.kind, + rootThreadId: event.payload.parentRelation.rootThreadId, + parentThreadId: + event.payload.parentRelation.kind === "subagent" + ? event.payload.parentRelation.parentThreadId + : null, + parentTurnId: + event.payload.parentRelation.kind === "subagent" + ? event.payload.parentRelation.parentTurnId + : null, + parentItemId: + event.payload.parentRelation.kind === "subagent" + ? event.payload.parentRelation.parentItemId + : null, + parentActivitySequence: + event.payload.parentRelation.kind === "subagent" + ? event.payload.parentRelation.parentActivitySequence + : 0, + providerThreadId: + event.payload.parentRelation.kind === "subagent" + ? event.payload.parentRelation.providerThreadId + : null, + titleSeed: + event.payload.parentRelation.kind === "subagent" + ? event.payload.parentRelation.titleSeed + : null, + subagentDepth: + event.payload.parentRelation.kind === "subagent" + ? event.payload.parentRelation.depth + : 0, + subagentStartedAt: + event.payload.parentRelation.kind === "subagent" + ? event.payload.parentRelation.startedAt + : null, + subagentCompletedAt: + event.payload.parentRelation.kind === "subagent" + ? event.payload.parentRelation.completedAt + : null, + subagentStatus: + event.payload.parentRelation.kind === "subagent" + ? event.payload.parentRelation.status + : null, + } + : {}), updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 7db2a23e5ec..faae1f7f9d2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -310,6 +310,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, deletedAt: null, + parentRelation: { + kind: "root", + rootThreadId: ThreadId.make("thread-1"), + }, messages: [ { id: asMessageId("message-1"), @@ -419,6 +423,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + parentRelation: { + kind: "root", + rootThreadId: ThreadId.make("thread-1"), + }, session: { threadId: ThreadId.make("thread-1"), status: "running", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e629d1604b3..126a5bf3917 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -223,6 +223,44 @@ function mapSessionRow( }; } +function mapThreadParentRelation( + row: Schema.Schema.Type<typeof ProjectionThreadDbRowSchema>, +): OrchestrationThread["parentRelation"] { + if (row.parentKind === "subagent") { + if ( + row.parentThreadId === null || + row.parentItemId === null || + row.providerThreadId === null || + row.subagentStartedAt === null || + row.subagentStatus === null + ) { + return { + kind: "root", + rootThreadId: row.rootThreadId, + }; + } + return { + kind: "subagent", + rootThreadId: row.rootThreadId, + parentThreadId: row.parentThreadId, + parentTurnId: row.parentTurnId, + parentItemId: row.parentItemId, + parentActivitySequence: row.parentActivitySequence, + providerThreadId: row.providerThreadId, + titleSeed: row.titleSeed, + depth: row.subagentDepth, + startedAt: row.subagentStartedAt, + completedAt: row.subagentCompletedAt, + status: row.subagentStatus, + }; + } + + return { + kind: "root", + rootThreadId: row.rootThreadId, + }; +} + function mapProjectShellRow( row: Schema.Schema.Type<typeof ProjectionProjectDbRowSchema>, repositoryIdentity: OrchestrationProject["repositoryIdentity"], @@ -329,6 +367,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + COALESCE(parent_kind, 'root') AS "parentKind", + COALESCE(NULLIF(root_thread_id, ''), thread_id) AS "rootThreadId", + parent_thread_id AS "parentThreadId", + parent_turn_id AS "parentTurnId", + parent_item_id AS "parentItemId", + COALESCE(parent_activity_sequence, 0) AS "parentActivitySequence", + provider_thread_id AS "providerThreadId", + title_seed AS "titleSeed", + COALESCE(subagent_depth, 0) AS "subagentDepth", + subagent_started_at AS "subagentStartedAt", + subagent_completed_at AS "subagentCompletedAt", + subagent_status AS "subagentStatus", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -357,6 +407,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + COALESCE(parent_kind, 'root') AS "parentKind", + COALESCE(NULLIF(root_thread_id, ''), thread_id) AS "rootThreadId", + parent_thread_id AS "parentThreadId", + parent_turn_id AS "parentTurnId", + parent_item_id AS "parentItemId", + COALESCE(parent_activity_sequence, 0) AS "parentActivitySequence", + provider_thread_id AS "providerThreadId", + title_seed AS "titleSeed", + COALESCE(subagent_depth, 0) AS "subagentDepth", + subagent_started_at AS "subagentStartedAt", + subagent_completed_at AS "subagentCompletedAt", + subagent_status AS "subagentStatus", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -387,6 +449,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + COALESCE(parent_kind, 'root') AS "parentKind", + COALESCE(NULLIF(root_thread_id, ''), thread_id) AS "rootThreadId", + parent_thread_id AS "parentThreadId", + parent_turn_id AS "parentTurnId", + parent_item_id AS "parentItemId", + COALESCE(parent_activity_sequence, 0) AS "parentActivitySequence", + provider_thread_id AS "providerThreadId", + title_seed AS "titleSeed", + COALESCE(subagent_depth, 0) AS "subagentDepth", + subagent_started_at AS "subagentStartedAt", + subagent_completed_at AS "subagentCompletedAt", + subagent_status AS "subagentStatus", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -711,6 +785,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE project_id = ${projectId} AND deleted_at IS NULL AND archived_at IS NULL + AND COALESCE(parent_kind, 'root') = 'root' ORDER BY created_at ASC, thread_id ASC LIMIT 1 `, @@ -749,6 +824,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + COALESCE(parent_kind, 'root') AS "parentKind", + COALESCE(NULLIF(root_thread_id, ''), thread_id) AS "rootThreadId", + parent_thread_id AS "parentThreadId", + parent_turn_id AS "parentTurnId", + parent_item_id AS "parentItemId", + COALESCE(parent_activity_sequence, 0) AS "parentActivitySequence", + provider_thread_id AS "providerThreadId", + title_seed AS "titleSeed", + COALESCE(subagent_depth, 0) AS "subagentDepth", + subagent_started_at AS "subagentStartedAt", + subagent_completed_at AS "subagentCompletedAt", + subagent_status AS "subagentStatus", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1186,6 +1273,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: row.updatedAt, archivedAt: row.archivedAt, deletedAt: row.deletedAt, + parentRelation: mapThreadParentRelation(row), messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], activities: activitiesByThread.get(row.threadId) ?? [], @@ -1384,6 +1472,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: row.updatedAt, archivedAt: row.archivedAt, deletedAt: row.deletedAt, + parentRelation: mapThreadParentRelation(row), messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], activities: [], @@ -1512,6 +1601,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + parentRelation: mapThreadParentRelation(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1646,6 +1736,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + parentRelation: mapThreadParentRelation(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1886,6 +1977,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + parentRelation: mapThreadParentRelation(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -1981,6 +2073,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, deletedAt: null, + parentRelation: mapThreadParentRelation(threadRow.value), messages: messageRows.map((row) => { const message = { id: row.messageId, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 77f9a2ed904..60d43682a4c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -17,6 +17,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, + ProviderItemId, ProjectId, ThreadId, TurnId, @@ -64,6 +65,7 @@ import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitW const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const asMessageId = (value: string): MessageId => MessageId.make(value); +const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) => @@ -1636,6 +1638,86 @@ describe("ProviderCommandReactor", () => { }); }); + it("routes subagent turn interrupts through the provider-bound root thread", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const childThreadId = ThreadId.make("subagent-thread-1"); + const childTurnId = asTurnId("child-turn-1"); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-root-session-set"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-subagent-thread-create"), + threadId: childThreadId, + projectId: asProjectId("project-1"), + title: "Subagent Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + parentRelation: { + kind: "subagent", + rootThreadId: ThreadId.make("thread-1"), + parentThreadId: ThreadId.make("thread-1"), + parentTurnId: asTurnId("turn-1"), + parentItemId: asProviderItemId("parent-item-1"), + parentActivitySequence: 0, + providerThreadId: "provider-child-thread-1", + titleSeed: "Investigate child task", + depth: 1, + startedAt: now, + completedAt: null, + status: "running", + }, + createdAt: now, + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-subagent-turn-interrupt"), + threadId: childThreadId, + turnId: childTurnId, + createdAt: now, + }), + ); + + await waitFor(() => harness.interruptTurn.mock.calls.length === 1); + expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({ + threadId: "thread-1", + turnId: childTurnId, + }); + await waitFor(async () => { + const readModel = await harness.readModel(); + const child = readModel.threads.find((entry) => entry.id === childThreadId); + return ( + child?.parentRelation?.kind === "subagent" && child.parentRelation.status === "stopped" + ); + }); + }); + it("starts a fresh session when only projected session state exists", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9c7a7c94bb1..01ef5de8741 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -867,8 +867,12 @@ const make = Effect.gen(function* () { if (!thread) { return; } - const hasSession = thread.session && thread.session.status !== "stopped"; - if (!hasSession) { + const subagentRelation = + thread.parentRelation?.kind === "subagent" ? thread.parentRelation : null; + const providerThread = subagentRelation + ? yield* resolveThread(subagentRelation.rootThreadId) + : thread; + if (!providerThread?.session || providerThread.session.status === "stopped") { return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.turn.interrupt.failed", @@ -879,8 +883,25 @@ const make = Effect.gen(function* () { }); } - // Orchestration turn ids are not provider turn ids, so interrupt by session. - yield* providerService.interruptTurn({ threadId: event.payload.threadId }); + const childTurnId = subagentRelation + ? (event.payload.turnId ?? thread.latestTurn?.turnId) + : undefined; + yield* providerService.interruptTurn({ + threadId: providerThread.id, + ...(childTurnId ? { turnId: childTurnId } : {}), + }); + if (subagentRelation) { + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* serverCommandId("subagent-interrupt-status"), + threadId: event.payload.threadId, + parentRelation: { + ...subagentRelation, + completedAt: event.payload.createdAt, + status: "stopped", + }, + }); + } }); const processApprovalResponseRequested = Effect.fn("processApprovalResponseRequested")(function* ( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 64955590235..1f1d2c02dc2 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -49,6 +49,7 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; function makeTestServerSettingsLayer(overrides: Partial<ServerSettings> = {}) { @@ -217,7 +218,10 @@ describe("ProviderRuntimeIngestion", () => { } }); - async function createHarness(options?: { serverSettings?: Partial<ServerSettings> }) { + async function createHarness(options?: { + serverSettings?: Partial<ServerSettings>; + textGeneration?: Partial<TextGenerationShape>; + }) { const workspaceRoot = makeTempDir("t3-provider-project-"); fs.mkdirSync(path.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); @@ -239,6 +243,16 @@ describe("ProviderRuntimeIngestion", () => { Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), + Layer.provideMerge( + Layer.succeed(TextGeneration, { + generateCommitMessage: () => Effect.die("generateCommitMessage should not be called"), + generatePrContent: () => Effect.die("generatePrContent should not be called"), + generateBranchName: () => Effect.die("generateBranchName should not be called"), + generateThreadTitle: + options?.textGeneration?.generateThreadTitle ?? + (() => Effect.succeed({ title: "Generated subagent title" })), + }), + ), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ); @@ -2531,6 +2545,256 @@ describe("ProviderRuntimeIngestion", () => { ).toBe(true); }); + it("generates a concise title for projected subagent child threads", async () => { + const rawPrompt = + "You are Child Alpha. Do not edit any files. Run exactly this harmless shell command."; + const harness = await createHarness({ + textGeneration: { + generateThreadTitle: (input) => { + expect(input.message).toBe(rawPrompt); + return Effect.succeed({ title: "Run harmless command" }); + }, + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + const childThreadId = asThreadId("subagent-title-test"); + + harness.emit({ + type: "item.started", + eventId: asEventId("evt-subagent-title-started"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-9"), + itemId: asItemId("parent-item-title-test"), + payload: { + itemType: "collab_agent_tool_call", + status: "in_progress", + title: "Subagent", + detail: rawPrompt, + data: { + subagentChildren: [ + { + providerThreadId: "provider-child-title-test", + childThreadId, + parentItemId: "parent-item-title-test", + titleSeed: rawPrompt, + }, + ], + }, + }, + }); + + const childThread = await waitForThread( + harness.readModel, + (entry) => entry.title === "Run harmless command", + 2000, + childThreadId, + ); + + expect(childThread.title).toBe("Run harmless command"); + expect(childThread.parentRelation).toMatchObject({ + kind: "subagent", + titleSeed: rawPrompt, + }); + }); + + it("projects the subagent launch prompt as the child thread's initial user message", async () => { + const rawPrompt = + "CHILD_INITIAL_PROMPT_MARKER_TEST: Do not edit files. Return CHILD_OUTPUT_MARKER_TEST."; + const harness = await createHarness({ + textGeneration: { + generateThreadTitle: () => Effect.succeed({ title: "Marker test" }), + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + const childThreadId = asThreadId("subagent-prompt-test"); + + harness.emit({ + type: "item.started", + eventId: asEventId("evt-subagent-prompt-started"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-9"), + itemId: asItemId("parent-item-prompt-test"), + payload: { + itemType: "collab_agent_tool_call", + status: "in_progress", + title: "Subagent", + detail: rawPrompt, + data: { + subagentChildren: [ + { + providerThreadId: "provider-child-prompt-test", + childThreadId, + parentItemId: "parent-item-prompt-test", + titleSeed: rawPrompt, + }, + ], + }, + }, + }); + + await waitForThread( + harness.readModel, + (entry) => entry.parentRelation?.kind === "subagent", + 2000, + childThreadId, + ); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-subagent-prompt-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-9"), + itemId: asItemId("parent-item-prompt-test"), + payload: { + itemType: "collab_agent_tool_call", + status: "completed", + title: "Subagent", + detail: rawPrompt, + data: { + subagentChildren: [ + { + providerThreadId: "provider-child-prompt-test", + childThreadId, + parentItemId: "parent-item-prompt-test", + rawPrompt, + titleSeed: rawPrompt, + }, + ], + }, + }, + }); + + const childThread = await waitForThread( + harness.readModel, + (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.role === "user" && message.text === rawPrompt, + ), + 2000, + childThreadId, + ); + + expect(childThread.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "subagent-prompt:subagent-prompt-test:parent-item-prompt-test", + role: "user", + text: rawPrompt, + turnId: null, + streaming: false, + }), + ]), + ); + }); + + it("does not project a whitespace-only subagent prompt", async () => { + const harness = await createHarness({ + textGeneration: { + generateThreadTitle: () => Effect.succeed({ title: "Whitespace child task" }), + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + const childThreadId = asThreadId("subagent-whitespace-prompt-test"); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-subagent-whitespace-prompt-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-9"), + itemId: asItemId("parent-item-whitespace-prompt-test"), + payload: { + itemType: "collab_agent_tool_call", + status: "completed", + title: "Subagent", + detail: "Whitespace child task", + data: { + subagentChildren: [ + { + providerThreadId: "provider-child-whitespace-prompt-test", + childThreadId, + parentItemId: "parent-item-whitespace-prompt-test", + rawPrompt: "\n ", + titleSeed: "Whitespace child task", + }, + ], + }, + }, + }); + + const childThread = await waitForThread( + harness.readModel, + (entry) => entry.title === "Whitespace child task", + 2000, + childThreadId, + ); + expect(childThread.messages).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "subagent-prompt:subagent-whitespace-prompt-test:parent-item-whitespace-prompt-test", + }), + ]), + ); + }); + + it("does not fabricate a child prompt from title metadata", async () => { + const harness = await createHarness({ + textGeneration: { + generateThreadTitle: () => Effect.succeed({ title: "Summarized child task" }), + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + const childThreadId = asThreadId("subagent-title-only-prompt-test"); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-subagent-title-only-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-9"), + itemId: asItemId("parent-item-title-only-test"), + payload: { + itemType: "collab_agent_tool_call", + status: "completed", + title: "Subagent", + detail: "Summarized child task", + data: { + subagentChildren: [ + { + providerThreadId: "provider-child-title-only-test", + childThreadId, + parentItemId: "parent-item-title-only-test", + titleSeed: "Summarized child task", + }, + ], + }, + }, + }); + + const childThread = await waitForThread( + harness.readModel, + (entry) => entry.title === "Summarized child task", + 2000, + childThreadId, + ); + expect(childThread.messages).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "subagent-prompt:subagent-title-only-prompt-test:parent-item-title-only-test", + }), + ]), + ); + }); + it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3e5978f4846..e2812530862 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -8,6 +8,7 @@ import { type OrchestrationProposedPlanId, CheckpointRef, isToolLifecycleItemType, + ProviderItemId, ThreadId, type ThreadTokenUsageSnapshot, TurnId, @@ -15,6 +16,8 @@ import { type OrchestrationProposedPlan, type OrchestrationThread, type OrchestrationThreadActivity, + type OrchestrationThreadParentRelation, + type OrchestrationThreadShell, type ProviderRuntimeEvent, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; @@ -24,6 +27,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -38,9 +42,23 @@ import { type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; +interface RuntimeSubagentChild { + readonly childThreadId: ThreadId; + readonly providerThreadId: string; + readonly parentItemId: ProviderItemId; + readonly rawPrompt: string | null; + readonly titleSeed: string | null; +} + +type SubagentThreadParentRelation = Extract< + OrchestrationThreadParentRelation, + { kind: "subagent" } +>; + interface AssistantSegmentState { baseKey: string; nextSegmentIndex: number; @@ -75,6 +93,90 @@ function toTurnId(value: TurnId | string | undefined): TurnId | undefined { return value === undefined ? undefined : TurnId.make(String(value)); } +function asRecord(value: unknown): Record<string, unknown> | undefined { + return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined; +} + +function readRuntimeSubagentChildren( + event: ProviderRuntimeEvent, +): ReadonlyArray<RuntimeSubagentChild> { + if ( + event.type !== "item.started" && + event.type !== "item.updated" && + event.type !== "item.completed" + ) { + return []; + } + if (event.payload.itemType !== "collab_agent_tool_call") { + return []; + } + + const data = asRecord(event.payload.data); + const children = Array.isArray(data?.subagentChildren) ? data.subagentChildren : []; + return children.flatMap((entry): RuntimeSubagentChild[] => { + const record = asRecord(entry); + const childThreadId = + typeof record?.childThreadId === "string" && record.childThreadId.trim().length > 0 + ? ThreadId.make(record.childThreadId) + : null; + const providerThreadId = + typeof record?.providerThreadId === "string" && record.providerThreadId.trim().length > 0 + ? record.providerThreadId + : null; + const parentItemId = + typeof record?.parentItemId === "string" && record.parentItemId.trim().length > 0 + ? ProviderItemId.make(record.parentItemId) + : event.itemId + ? ProviderItemId.make(String(event.itemId)) + : null; + if (!childThreadId || !providerThreadId || !parentItemId) { + return []; + } + const titleSeed = + typeof record?.titleSeed === "string" && record.titleSeed.trim().length > 0 + ? record.titleSeed.trim() + : null; + const rawPrompt = + typeof record?.rawPrompt === "string" && record.rawPrompt.trim().length > 0 + ? record.rawPrompt.trim() + : null; + return [ + { + childThreadId, + providerThreadId, + parentItemId, + rawPrompt, + titleSeed, + }, + ]; + }); +} + +function runtimeEventSequence(event: ProviderRuntimeEvent): number | undefined { + const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; + return eventWithSequence.sessionSequence; +} + +function subagentTerminalStatusFromRuntimeEvent( + event: ProviderRuntimeEvent, +): SubagentThreadParentRelation["status"] | null { + if (event.type === "session.exited") { + return "stopped"; + } + if (event.type !== "turn.completed") { + return null; + } + switch (normalizeRuntimeTurnState(event.payload.state)) { + case "completed": + return "completed"; + case "failed": + return "errored"; + case "interrupted": + case "cancelled": + return "interrupted"; + } +} + function toApprovalRequestId(value: string | undefined): ApprovalRequestId | undefined { return value === undefined ? undefined : ApprovalRequestId.make(value); } @@ -265,12 +367,8 @@ function requestKindFromCanonicalRequestType( function runtimeEventToActivities( event: ProviderRuntimeEvent, ): ReadonlyArray<OrchestrationThreadActivity> { - const maybeSequence = (() => { - const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; - return eventWithSequence.sessionSequence !== undefined - ? { sequence: eventWithSequence.sessionSequence } - : {}; - })(); + const eventSequence = runtimeEventSequence(event); + const maybeSequence = eventSequence !== undefined ? { sequence: eventSequence } : {}; switch (event.type) { case "request.opened": { if (event.payload.requestType === "tool_user_input") { @@ -613,6 +711,7 @@ function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -634,6 +733,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; const serverSettingsService = yield* ServerSettingsService; + const textGeneration = yield* TextGeneration; const providerCommandId = (event: ProviderRuntimeEvent, tag: string) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)), @@ -665,6 +765,7 @@ const make = Effect.gen(function* () { timeToLive: BUFFERED_PROPOSED_PLAN_BY_ID_TTL, lookup: () => Effect.succeed({ text: "", createdAt: "" }), }); + const syntheticChildShellById = yield* Ref.make(new Map<ThreadId, OrchestrationThreadShell>()); const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery @@ -673,11 +774,77 @@ const make = Effect.gen(function* () { }); const resolveThreadShell = Effect.fn("resolveThreadShell")(function* (threadId: ThreadId) { - return yield* projectionSnapshotQuery + const projected = yield* projectionSnapshotQuery .getThreadShellById(threadId) .pipe(Effect.map(Option.getOrUndefined)); + if (projected) { + return projected; + } + return (yield* Ref.get(syntheticChildShellById)).get(threadId); }); + const maybeGenerateSubagentThreadTitle = Effect.fn("maybeGenerateSubagentThreadTitle")( + function* (input: { + readonly childThreadId: ThreadId; + readonly titleSeed: string | null; + readonly cwd: string; + readonly createdAt: string; + }) { + const titleSeed = input.titleSeed?.trim(); + if (!titleSeed) { + return; + } + + yield* Effect.gen(function* () { + const { textGenerationModelSelection: modelSelection } = + yield* serverSettingsService.getSettings; + const generated = yield* textGeneration.generateThreadTitle({ + cwd: input.cwd, + message: titleSeed, + modelSelection, + }); + if (!generated.title.trim()) { + return; + } + + const latestChild = yield* resolveThreadShell(input.childThreadId); + if ( + !latestChild || + (latestChild.title.trim() !== titleSeed && latestChild.title.trim() !== "Subagent") + ) { + return; + } + + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make(`provider:subagent-thread-title:${input.childThreadId}`), + threadId: input.childThreadId, + title: generated.title, + }); + yield* Ref.update(syntheticChildShellById, (current) => { + const cached = current.get(input.childThreadId); + if (!cached) { + return current; + } + const next = new Map(current); + next.set(input.childThreadId, { + ...cached, + title: generated.title, + updatedAt: input.createdAt, + }); + return next; + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider runtime ingestion failed to generate subagent title", { + threadId: input.childThreadId, + cause: Cause.pretty(cause), + }), + ), + ); + }, + ); + const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( Effect.flatMap((existingIds) => @@ -1222,6 +1389,121 @@ const make = Effect.gen(function* () { const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; + const subagentChildren = readRuntimeSubagentChildren(event); + if (subagentChildren.length > 0) { + const rootThreadId = + thread.parentRelation?.kind === "subagent" + ? thread.parentRelation.rootThreadId + : thread.id; + const parentDepth = + thread.parentRelation?.kind === "subagent" ? thread.parentRelation.depth : 0; + yield* Effect.forEach( + subagentChildren, + (child) => + Effect.gen(function* () { + const existingChild = yield* resolveThreadShell(child.childThreadId); + const existingRelation = + existingChild?.parentRelation?.kind === "subagent" + ? existingChild.parentRelation + : null; + const parentRelation: SubagentThreadParentRelation = { + kind: "subagent" as const, + rootThreadId, + parentThreadId: thread.id, + parentTurnId: existingRelation?.parentTurnId ?? eventTurnId ?? null, + parentItemId: existingRelation?.parentItemId ?? child.parentItemId, + parentActivitySequence: + existingRelation?.parentActivitySequence ?? runtimeEventSequence(event) ?? 0, + providerThreadId: child.providerThreadId, + titleSeed: existingRelation?.titleSeed ?? child.titleSeed, + depth: parentDepth + 1, + startedAt: existingRelation?.startedAt ?? now, + completedAt: existingRelation?.completedAt ?? null, + status: existingRelation?.status ?? "running", + }; + if (!existingChild) { + const title = "Subagent"; + yield* Ref.update(syntheticChildShellById, (current) => { + const next = new Map(current); + next.set(child.childThreadId, { + id: child.childThreadId, + projectId: thread.projectId, + title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + parentRelation, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }); + return next; + }); + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: CommandId.make( + `provider:subagent-thread-create:${child.childThreadId}`, + ), + threadId: child.childThreadId, + projectId: thread.projectId, + title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + parentRelation, + createdAt: now, + }); + yield* maybeGenerateSubagentThreadTitle({ + childThreadId: child.childThreadId, + titleSeed: parentRelation.titleSeed, + cwd: thread.worktreePath ?? process.cwd(), + createdAt: now, + }).pipe(Effect.forkScoped); + } + if (child.rawPrompt) { + const childThreadIdText = String(child.childThreadId); + const parentItemIdText = String(child.parentItemId); + yield* orchestrationEngine.dispatch({ + type: "thread.message.user.append", + commandId: CommandId.make( + `provider:subagent-thread-prompt:${childThreadIdText}:${parentItemIdText}`, + ), + threadId: child.childThreadId, + messageId: MessageId.make( + `subagent-prompt:${childThreadIdText}:${parentItemIdText}`, + ), + text: child.rawPrompt, + createdAt: now, + }); + } + yield* Ref.update(syntheticChildShellById, (current) => { + const cached = current.get(child.childThreadId); + if (!cached) { + return current; + } + const next = new Map(current); + next.set(child.childThreadId, { + ...cached, + updatedAt: now, + parentRelation, + }); + return next; + }); + }), + { discard: true }, + ); + } + const conflictsWithActiveTurn = activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; @@ -1355,6 +1637,44 @@ const make = Effect.gen(function* () { }, createdAt: now, }); + + if (thread.parentRelation?.kind === "subagent") { + const terminalStatus = subagentTerminalStatusFromRuntimeEvent(event); + const shouldUpdateSubagentTerminalStatus = + terminalStatus !== null && + thread.parentRelation.status === "running" && + !( + event.type === "session.exited" && + thread.parentRelation.completedAt !== null && + thread.parentRelation.status !== "running" + ); + if (shouldUpdateSubagentTerminalStatus) { + const terminalRelation: SubagentThreadParentRelation = { + ...thread.parentRelation, + completedAt: thread.parentRelation.completedAt ?? now, + status: terminalStatus, + }; + yield* Ref.update(syntheticChildShellById, (current) => { + const cached = current.get(thread.id); + if (!cached) { + return current; + } + const next = new Map(current); + next.set(thread.id, { + ...cached, + updatedAt: now, + parentRelation: terminalRelation, + }); + return next; + }); + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* providerCommandId(event, "subagent-thread-terminal"), + threadId: thread.id, + parentRelation: terminalRelation, + }); + } + } } } diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0d4af771ca8..ab9d00f9d83 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -239,6 +239,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, + ...(command.parentRelation !== undefined + ? { parentRelation: command.parentRelation } + : {}), createdAt: command.createdAt, updatedAt: command.createdAt, }, @@ -335,6 +338,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(command.branch !== undefined ? { branch: command.branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(command.parentRelation !== undefined + ? { parentRelation: command.parentRelation } + : {}), updatedAt: occurredAt, }, }; @@ -654,6 +660,33 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.message.user.append": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.messageId, + role: "user", + text: command.text, + turnId: command.turnId ?? null, + streaming: false, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + } + case "thread.proposed-plan.upsert": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..e2c107f8370 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -287,6 +287,9 @@ export function projectEvent( updatedAt: payload.updatedAt, archivedAt: null, deletedAt: null, + ...(payload.parentRelation !== undefined + ? { parentRelation: payload.parentRelation } + : {}), messages: [], activities: [], checkpoints: [], @@ -348,6 +351,9 @@ export function projectEvent( : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.parentRelation !== undefined + ? { parentRelation: payload.parentRelation } + : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index a2069e62a14..c6430313a3c 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -87,6 +87,18 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + parentKind: "root", + rootThreadId: ThreadId.make("thread-null-options"), + parentThreadId: null, + parentTurnId: null, + parentItemId: null, + parentActivitySequence: 0, + providerThreadId: null, + titleSeed: null, + subagentDepth: 0, + subagentStartedAt: null, + subagentCompletedAt: null, + subagentStatus: null, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c15..694347b238b 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -39,6 +39,18 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + parent_kind, + root_thread_id, + parent_thread_id, + parent_turn_id, + parent_item_id, + parent_activity_sequence, + provider_thread_id, + title_seed, + subagent_depth, + subagent_started_at, + subagent_completed_at, + subagent_status, latest_turn_id, created_at, updated_at, @@ -58,6 +70,18 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${row.parentKind}, + ${row.rootThreadId}, + ${row.parentThreadId}, + ${row.parentTurnId}, + ${row.parentItemId}, + ${row.parentActivitySequence}, + ${row.providerThreadId}, + ${row.titleSeed}, + ${row.subagentDepth}, + ${row.subagentStartedAt}, + ${row.subagentCompletedAt}, + ${row.subagentStatus}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, @@ -77,6 +101,66 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + parent_kind = CASE + WHEN projection_threads.parent_kind = 'subagent' AND excluded.parent_kind != 'subagent' + THEN projection_threads.parent_kind + ELSE excluded.parent_kind + END, + root_thread_id = CASE + WHEN projection_threads.parent_kind = 'subagent' AND excluded.parent_kind != 'subagent' + THEN projection_threads.root_thread_id + ELSE excluded.root_thread_id + END, + parent_thread_id = CASE + WHEN projection_threads.parent_kind = 'subagent' AND excluded.parent_kind != 'subagent' + THEN projection_threads.parent_thread_id + ELSE excluded.parent_thread_id + END, + parent_turn_id = CASE + WHEN projection_threads.parent_kind = 'subagent' AND excluded.parent_kind != 'subagent' + THEN projection_threads.parent_turn_id + ELSE excluded.parent_turn_id + END, + parent_item_id = CASE + WHEN projection_threads.parent_kind = 'subagent' AND excluded.parent_kind != 'subagent' + THEN projection_threads.parent_item_id + ELSE excluded.parent_item_id + END, + parent_activity_sequence = CASE + WHEN projection_threads.parent_kind = 'subagent' AND excluded.parent_kind != 'subagent' + THEN projection_threads.parent_activity_sequence + ELSE excluded.parent_activity_sequence + END, + provider_thread_id = CASE + WHEN projection_threads.parent_kind = 'subagent' AND excluded.parent_kind != 'subagent' + THEN projection_threads.provider_thread_id + ELSE excluded.provider_thread_id + END, + title_seed = CASE + WHEN projection_threads.parent_kind = 'subagent' AND excluded.parent_kind != 'subagent' + THEN projection_threads.title_seed + ELSE excluded.title_seed + END, + subagent_depth = CASE + WHEN projection_threads.parent_kind = 'subagent' AND excluded.parent_kind != 'subagent' + THEN projection_threads.subagent_depth + ELSE excluded.subagent_depth + END, + subagent_started_at = CASE + WHEN projection_threads.parent_kind = 'subagent' AND excluded.parent_kind != 'subagent' + THEN projection_threads.subagent_started_at + ELSE excluded.subagent_started_at + END, + subagent_completed_at = CASE + WHEN projection_threads.parent_kind = 'subagent' AND excluded.parent_kind != 'subagent' + THEN projection_threads.subagent_completed_at + ELSE excluded.subagent_completed_at + END, + subagent_status = CASE + WHEN projection_threads.parent_kind = 'subagent' AND excluded.parent_kind != 'subagent' + THEN projection_threads.subagent_status + ELSE excluded.subagent_status + END, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -103,6 +187,18 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + COALESCE(parent_kind, 'root') AS "parentKind", + COALESCE(NULLIF(root_thread_id, ''), thread_id) AS "rootThreadId", + parent_thread_id AS "parentThreadId", + parent_turn_id AS "parentTurnId", + parent_item_id AS "parentItemId", + COALESCE(parent_activity_sequence, 0) AS "parentActivitySequence", + provider_thread_id AS "providerThreadId", + title_seed AS "titleSeed", + COALESCE(subagent_depth, 0) AS "subagentDepth", + subagent_started_at AS "subagentStartedAt", + subagent_completed_at AS "subagentCompletedAt", + subagent_status AS "subagentStatus", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -131,6 +227,18 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + COALESCE(parent_kind, 'root') AS "parentKind", + COALESCE(NULLIF(root_thread_id, ''), thread_id) AS "rootThreadId", + parent_thread_id AS "parentThreadId", + parent_turn_id AS "parentTurnId", + parent_item_id AS "parentItemId", + COALESCE(parent_activity_sequence, 0) AS "parentActivitySequence", + provider_thread_id AS "providerThreadId", + title_seed AS "titleSeed", + COALESCE(subagent_depth, 0) AS "subagentDepth", + subagent_started_at AS "subagentStartedAt", + subagent_completed_at AS "subagentCompletedAt", + subagent_status AS "subagentStatus", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee259..09aaee70780 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,8 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ProjectionThreadParentRelation.ts"; +import Migration0034 from "./Migrations/034_BackfillEmptyProjectionThreadRootIds.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +91,8 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ProjectionThreadParentRelation", Migration0033], + [34, "BackfillEmptyProjectionThreadRootIds", Migration0034], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ProjectionThreadParentRelation.ts b/apps/server/src/persistence/Migrations/033_ProjectionThreadParentRelation.ts new file mode 100644 index 00000000000..70e86b85ea7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ProjectionThreadParentRelation.ts @@ -0,0 +1,35 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql`ALTER TABLE projection_threads ADD COLUMN parent_kind TEXT NOT NULL DEFAULT 'root'`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN root_thread_id TEXT NOT NULL DEFAULT ''`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN parent_thread_id TEXT`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN parent_turn_id TEXT`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN parent_item_id TEXT`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN parent_activity_sequence INTEGER NOT NULL DEFAULT 0`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN provider_thread_id TEXT`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN title_seed TEXT`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN subagent_depth INTEGER NOT NULL DEFAULT 0`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN subagent_started_at TEXT`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN subagent_completed_at TEXT`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN subagent_status TEXT`; + + yield* sql` + UPDATE projection_threads + SET root_thread_id = thread_id + WHERE root_thread_id = '' + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_parent_relation + ON projection_threads(parent_thread_id, subagent_status, subagent_started_at, thread_id) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_root_relation + ON projection_threads(root_thread_id, deleted_at, archived_at, thread_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/034_BackfillEmptyProjectionThreadRootIds.test.ts b/apps/server/src/persistence/Migrations/034_BackfillEmptyProjectionThreadRootIds.test.ts new file mode 100644 index 00000000000..b0bb4e7e6cd --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_BackfillEmptyProjectionThreadRootIds.test.ts @@ -0,0 +1,94 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("034_BackfillEmptyProjectionThreadRootIds", (it) => { + it.effect("backfills empty root thread ids left by earlier projection rows", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 33 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + parent_kind, + root_thread_id, + parent_thread_id, + parent_turn_id, + parent_item_id, + parent_activity_sequence, + provider_thread_id, + title_seed, + subagent_depth, + subagent_started_at, + subagent_completed_at, + subagent_status, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES ( + 'thread-empty-root', + 'project-empty-root', + 'Empty root id', + '{"instanceId":"codex","model":"gpt-5.5","options":[]}', + 'full-access', + 'default', + NULL, + NULL, + 'root', + '', + NULL, + NULL, + NULL, + 0, + NULL, + NULL, + 0, + NULL, + NULL, + NULL, + NULL, + '2026-06-12T00:00:00.000Z', + '2026-06-12T00:00:00.000Z', + NULL, + NULL, + 0, + 0, + 0, + NULL + ) + `; + + yield* runMigrations({ toMigrationInclusive: 34 }); + + const rows = yield* sql<{ readonly rootThreadId: string }>` + SELECT root_thread_id AS "rootThreadId" + FROM projection_threads + WHERE thread_id = 'thread-empty-root' + `; + + assert.deepStrictEqual(rows, [{ rootThreadId: "thread-empty-root" }]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/034_BackfillEmptyProjectionThreadRootIds.ts b/apps/server/src/persistence/Migrations/034_BackfillEmptyProjectionThreadRootIds.ts new file mode 100644 index 00000000000..16c7b5107d0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_BackfillEmptyProjectionThreadRootIds.ts @@ -0,0 +1,12 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE projection_threads + SET root_thread_id = thread_id + WHERE TRIM(root_thread_id) = '' + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a..f57aeabb31c 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -12,6 +12,7 @@ import { NonNegativeInt, ProjectId, ProviderInteractionMode, + ProviderItemId, RuntimeMode, ThreadId, TurnId, @@ -32,6 +33,20 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + parentKind: Schema.Literals(["root", "subagent"]), + rootThreadId: ThreadId, + parentThreadId: Schema.NullOr(ThreadId), + parentTurnId: Schema.NullOr(TurnId), + parentItemId: Schema.NullOr(ProviderItemId), + parentActivitySequence: NonNegativeInt, + providerThreadId: Schema.NullOr(Schema.String), + titleSeed: Schema.NullOr(Schema.String), + subagentDepth: NonNegativeInt, + subagentStartedAt: Schema.NullOr(IsoDateTime), + subagentCompletedAt: Schema.NullOr(IsoDateTime), + subagentStatus: Schema.NullOr( + Schema.Literals(["running", "completed", "errored", "interrupted", "stopped"]), + ), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 7fef85c42e0..ba5eabd92c8 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -628,6 +628,160 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("buffers child subagent deltas until the parent collab tool completes", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); + + yield* runtime.emit({ + id: asEventId("evt-subagent-started"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/started", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("collab-1"), + payload: { + threadId: "thread-1", + turnId: "turn-1", + startedAtMs: 1_767_225_600_000, + item: { + id: "collab-1", + type: "collabAgentToolCall", + tool: "spawnAgent", + status: "inProgress", + prompt: "Inspect routing", + senderThreadId: "thread-1", + receiverThreadIds: ["child-thread-1"], + agentsStates: { + "child-thread-1": { + status: "running", + }, + }, + }, + }, + } satisfies ProviderEvent); + + yield* runtime.emit({ + id: asEventId("evt-subagent-delta-1"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/agentMessage/delta", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("child-msg-1"), + textDelta: "Subagent ", + payload: { + threadId: "child-thread-1", + turnId: "child-turn-1", + itemId: "child-msg-1", + delta: "Subagent ", + parentCollab: { + itemId: "collab-1", + detail: "Inspect routing", + }, + }, + } satisfies ProviderEvent); + yield* runtime.emit({ + id: asEventId("evt-subagent-delta-2"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/agentMessage/delta", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("child-msg-1"), + textDelta: "result", + payload: { + threadId: "child-thread-1", + turnId: "child-turn-1", + itemId: "child-msg-1", + delta: "result", + parentCollab: { + itemId: "collab-1", + detail: "Inspect routing", + }, + }, + } satisfies ProviderEvent); + yield* runtime.emit({ + id: asEventId("evt-subagent-completed"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("collab-1"), + payload: { + threadId: "thread-1", + turnId: "turn-1", + completedAtMs: 1_767_225_601_000, + item: { + id: "collab-1", + type: "collabAgentToolCall", + tool: "spawnAgent", + status: "completed", + prompt: "Inspect routing", + senderThreadId: "thread-1", + receiverThreadIds: ["child-thread-1"], + agentsStates: { + "child-thread-1": { + status: "completed", + }, + }, + }, + }, + } satisfies ProviderEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + + assert.equal(events.length, 2); + const runningEvent = events[0]; + assert.equal(runningEvent?.type, "item.updated"); + if (runningEvent?.type === "item.updated") { + assert.equal(runningEvent.payload.itemType, "collab_agent_tool_call"); + assert.equal(runningEvent.payload.status, "inProgress"); + assert.equal(runningEvent.payload.title, "Subagent"); + assert.equal(runningEvent.payload.detail, "Inspect routing"); + } + + const completedEvent = events[1]; + assert.equal(completedEvent?.type, "item.completed"); + if (completedEvent?.type !== "item.completed") { + return; + } + assert.equal(completedEvent.payload.itemType, "collab_agent_tool_call"); + assert.equal(completedEvent.payload.detail, "Inspect routing"); + const completedData = completedEvent.payload.data as Record<string, unknown>; + assert.deepEqual(completedData.parentCollab, { + itemId: "collab-1", + detail: "Inspect routing", + }); + assert.equal(completedData.toolCallId, "collab-1"); + assert.deepEqual(completedData.rawOutput, { + content: "Subagent result", + }); + assert.deepEqual(completedData.item, { + id: "collab-1", + type: "collabAgentToolCall", + tool: "spawnAgent", + status: "completed", + prompt: "Inspect routing", + senderThreadId: "thread-1", + receiverThreadIds: ["child-thread-1"], + agentsStates: { + "child-thread-1": { + status: "completed", + }, + }, + }); + }), + ); + it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 270126e934b..cc799d6448c 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -92,6 +92,30 @@ interface CodexAdapterSessionContext { stopped: boolean; } +interface BufferedSubagentOutput { + readonly parentCollab: { + readonly itemId: string; + readonly detail?: string | undefined; + }; + readonly content: string; +} + +function subagentOutputBufferKey(threadId: ThreadId, itemId: string): string { + return `${threadId}\0${itemId}`; +} + +function clearSubagentOutputBuffersForThread( + buffers: Map<string, BufferedSubagentOutput>, + threadId: ThreadId, +): void { + const prefix = `${threadId}\0`; + for (const key of buffers.keys()) { + if (key.startsWith(prefix)) { + buffers.delete(key); + } + } +} + function mapCodexRuntimeError( threadId: ThreadId, method: string, @@ -254,6 +278,8 @@ function itemTitle(itemType: CanonicalItemType, item?: CodexLifecycleItem): stri return "File change"; case "mcp_tool_call": return "MCP tool call"; + case "collab_agent_tool_call": + return "Subagent"; case "dynamic_tool_call": return "Tool call"; case "web_search": @@ -486,10 +512,187 @@ function mapItemLifecycle( }; } +function asRecord(value: unknown): Record<string, unknown> | undefined { + return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined; +} + +function parentCollabFromPayload( + payload: ProviderEvent["payload"], +): { itemId?: string | undefined; detail?: string | undefined } | undefined { + const parentCollab = asRecord(asRecord(payload)?.parentCollab); + if (!parentCollab) { + return undefined; + } + const itemId = + typeof parentCollab.itemId === "string" ? trimText(parentCollab.itemId) : undefined; + const detail = + typeof parentCollab.detail === "string" ? trimText(parentCollab.detail) : undefined; + return itemId || detail ? { itemId, detail } : undefined; +} + +function childCollabAgentMessageDelta(event: ProviderEvent): { + readonly parentCollab: { itemId?: string | undefined; detail?: string | undefined }; + readonly delta: string; + readonly payload: EffectCodexSchema.V2AgentMessageDeltaNotification | undefined; + readonly rawPayload: Record<string, unknown> | undefined; +} | null { + if (event.method !== "item/agentMessage/delta") { + return null; + } + const parentCollab = parentCollabFromPayload(event.payload); + if (!parentCollab) { + return null; + } + const payload = readPayload(EffectCodexSchema.V2AgentMessageDeltaNotification, event.payload); + const rawPayload = asRecord(event.payload); + const delta = + event.textDelta ?? + payload?.delta ?? + (typeof rawPayload?.delta === "string" ? rawPayload.delta : undefined); + if (!delta || delta.length === 0) { + return null; + } + + return { + parentCollab, + delta, + payload, + rawPayload, + }; +} + +function bufferChildCollabAgentMessageDelta( + event: ProviderEvent, + buffers: Map<string, BufferedSubagentOutput>, +): boolean { + const childDelta = childCollabAgentMessageDelta(event); + if (!childDelta?.parentCollab.itemId) { + return false; + } + + const key = subagentOutputBufferKey(event.threadId, childDelta.parentCollab.itemId); + const previous = buffers.get(key); + buffers.set(key, { + parentCollab: { + itemId: childDelta.parentCollab.itemId, + ...((childDelta.parentCollab.detail ?? previous?.parentCollab.detail) + ? { detail: childDelta.parentCollab.detail ?? previous?.parentCollab.detail } + : {}), + }, + content: `${previous?.content ?? ""}${childDelta.delta}`, + }); + return true; +} + +function collabItemIdFromLifecycleEvent(event: ProviderEvent): string | undefined { + if (event.method !== "item/started" && event.method !== "item/completed") { + return undefined; + } + const payload = + event.method === "item/started" + ? readPayload(EffectCodexSchema.V2ItemStartedNotification, event.payload) + : readPayload(EffectCodexSchema.V2ItemCompletedNotification, event.payload); + const item = payload?.item; + if (!item || toCanonicalItemType(item.type) !== "collab_agent_tool_call") { + return undefined; + } + return item.id; +} + +function drainBufferedSubagentOutput( + event: ProviderEvent, + buffers: Map<string, BufferedSubagentOutput>, +): BufferedSubagentOutput | undefined { + if (event.method !== "item/completed") { + return undefined; + } + const itemId = collabItemIdFromLifecycleEvent(event); + if (!itemId) { + return undefined; + } + const key = subagentOutputBufferKey(event.threadId, itemId); + const buffered = buffers.get(key); + if (buffered) { + buffers.delete(key); + } + return buffered; +} + +function attachBufferedSubagentOutput( + event: ProviderRuntimeEvent, + buffered: BufferedSubagentOutput | undefined, +): ProviderRuntimeEvent { + if (!buffered || event.type !== "item.completed") { + return event; + } + return { + ...event, + payload: { + ...event.payload, + data: { + ...asRecord(event.payload.data), + parentCollab: buffered.parentCollab, + toolCallId: buffered.parentCollab.itemId, + rawOutput: { + content: buffered.content, + }, + }, + }, + }; +} + +function mapChildCollabAgentMessageDelta( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): ProviderRuntimeEvent | undefined { + const childDelta = childCollabAgentMessageDelta(event); + if (!childDelta) { + return undefined; + } + + return { + ...runtimeEventBase(event, canonicalThreadId), + type: "item.updated", + payload: { + itemType: "collab_agent_tool_call", + status: "inProgress", + title: "Subagent", + ...(childDelta.parentCollab.detail ? { detail: childDelta.parentCollab.detail } : {}), + data: { + parentCollab: childDelta.parentCollab, + toolCallId: childDelta.parentCollab.itemId, + childThreadId: + childDelta.payload?.threadId ?? + (typeof childDelta.rawPayload?.threadId === "string" + ? childDelta.rawPayload.threadId + : undefined), + childItemId: + childDelta.payload?.itemId ?? + (typeof childDelta.rawPayload?.itemId === "string" + ? childDelta.rawPayload.itemId + : undefined), + rawOutput: { + content: childDelta.delta, + }, + }, + }, + }; +} + function mapToRuntimeEvents( event: ProviderEvent, canonicalThreadId: ThreadId, + subagentOutputBuffers?: Map<string, BufferedSubagentOutput>, ): ReadonlyArray<ProviderRuntimeEvent> { + if (subagentOutputBuffers && bufferChildCollabAgentMessageDelta(event, subagentOutputBuffers)) { + return []; + } + + const childCollabDelta = mapChildCollabAgentMessageDelta(event, canonicalThreadId); + if (childCollabDelta) { + return [childCollabDelta]; + } + if (event.kind === "error") { if (!event.message) { return []; @@ -828,6 +1031,18 @@ function mapToRuntimeEvents( if (event.method === "item/started") { const started = mapItemLifecycle(event, canonicalThreadId, "item.started"); + if (started?.type === "item.started" && started.payload.itemType === "collab_agent_tool_call") { + return [ + { + ...started, + type: "item.updated", + payload: { + ...started.payload, + status: "inProgress", + }, + }, + ]; + } return started ? [started] : []; } @@ -853,8 +1068,11 @@ function mapToRuntimeEvents( }, ]; } + const buffered = subagentOutputBuffers + ? drainBufferedSubagentOutput(event, subagentOutputBuffers) + : undefined; const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed"); - return completed ? [completed] : []; + return completed ? [attachBufferedSubagentOutput(completed, buffered)] : []; } if ( @@ -1365,6 +1583,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); const sessions = new Map<ThreadId, CodexAdapterSessionContext>(); + const subagentOutputBuffers = new Map<string, BufferedSubagentOutput>(); const startSession: CodexAdapterShape["startSession"] = (input) => Effect.scoped( @@ -1441,7 +1660,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const eventFiber = yield* Stream.runForEach(runtime.events, (event) => Effect.gen(function* () { yield* writeNativeEvent(event); - const runtimeEvents = mapToRuntimeEvents(event, event.threadId); + const runtimeEvents = mapToRuntimeEvents(event, event.threadId, subagentOutputBuffers); if (runtimeEvents.length === 0) { yield* Effect.logDebug("ignoring unhandled Codex provider event", { method: event.method, @@ -1652,6 +1871,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } session.stopped = true; sessions.delete(session.threadId); + clearSubagentOutputBuffersForThread(subagentOutputBuffers, session.threadId); yield* session.runtime.close.pipe(Effect.ignore); yield* Effect.ignore(Scope.close(session.scope, Exit.void)); yield* Fiber.interrupt(session.eventFiber).pipe(Effect.ignore); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 03957081ded..ddc5f84a89e 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/contracts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { createHash } from "node:crypto"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -230,6 +231,19 @@ interface PendingUserInput { readonly answers: Deferred.Deferred<ProviderUserInputAnswers>; } +interface CollabReceiverInfo { + readonly parentTurnId: TurnId | undefined; + readonly parentItemId: ProviderItemId | undefined; + readonly providerThreadId: string; + readonly childThreadId: ThreadId; + readonly rawPrompt: string | undefined; + readonly detail: string | undefined; +} + +type CollabToolCallNotificationItem = + | CodexRpc.ServerNotificationParamsByMethod["item/started"]["item"] + | CodexRpc.ServerNotificationParamsByMethod["item/completed"]["item"]; + type CodexServerNotification = { readonly [M in CodexRpc.ServerNotificationMethod]: { readonly method: M; @@ -581,15 +595,48 @@ function readRouteFields(notification: CodexServerNotification): { } } +function trimNotificationText(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function deterministicSubagentThreadId(input: { + readonly parentThreadId: ThreadId; + readonly providerThreadId: string; +}): ThreadId { + const hash = createHash("sha256") + .update(`${input.parentThreadId}\0${input.providerThreadId}`) + .digest("base64url") + .slice(0, 32); + return ThreadId.make(`subagent_${hash}`); +} + +function collabToolCallDetail(item: CollabToolCallNotificationItem): string | undefined { + const candidates = [ + "prompt" in item ? item.prompt : undefined, + "title" in item ? item.title : undefined, + "summary" in item ? item.summary : undefined, + "text" in item ? item.text : undefined, + ]; + for (const candidate of candidates) { + const detail = trimNotificationText(candidate); + if (detail) { + return detail; + } + } + return undefined; +} + +function collabToolCallPrompt(item: CollabToolCallNotificationItem): string | undefined { + const prompt = "prompt" in item ? item.prompt : undefined; + return typeof prompt === "string" ? trimNotificationText(prompt) : undefined; +} + function rememberCollabReceiverTurns( - collabReceiverTurns: Map<string, TurnId>, + collabReceiverTurns: Map<string, CollabReceiverInfo>, notification: CodexServerNotification, parentTurnId: TurnId | undefined, + parentThreadId: ThreadId, ): void { - if (!parentTurnId) { - return; - } - if (notification.method !== "item/started" && notification.method !== "item/completed") { return; } @@ -598,28 +645,83 @@ function rememberCollabReceiverTurns( return; } + const rawPrompt = collabToolCallPrompt(notification.params.item); + const detail = collabToolCallDetail(notification.params.item); + const parentItemId = ProviderItemId.make(notification.params.item.id); for (const receiverThreadId of notification.params.item.receiverThreadIds) { - collabReceiverTurns.set(receiverThreadId, parentTurnId); + const existing = collabReceiverTurns.get(receiverThreadId); + collabReceiverTurns.set(receiverThreadId, { + parentTurnId: existing?.parentTurnId ?? parentTurnId, + parentItemId: existing?.parentItemId ?? parentItemId, + providerThreadId: receiverThreadId, + childThreadId: + existing?.childThreadId ?? + deterministicSubagentThreadId({ + parentThreadId, + providerThreadId: receiverThreadId, + }), + rawPrompt: rawPrompt ?? existing?.rawPrompt, + detail: existing?.detail ?? detail, + }); } } -function shouldSuppressChildConversationNotification( - method: CodexRpc.ServerNotificationMethod, -): boolean { - return ( - method === "thread/started" || - method === "thread/status/changed" || - method === "thread/archived" || - method === "thread/unarchived" || - method === "thread/closed" || - method === "thread/compacted" || - method === "thread/name/updated" || - method === "thread/tokenUsage/updated" || - method === "turn/started" || - method === "turn/completed" || - method === "turn/plan/updated" || - method === "item/plan/delta" - ); +function resolveChildParentInfo(input: { + readonly collabReceiverTurns: Map<string, CollabReceiverInfo>; + readonly providerConversationId: string | undefined; + readonly currentProviderThreadId: string | undefined; +}): CollabReceiverInfo | undefined { + if (!input.providerConversationId) { + return undefined; + } + + const direct = input.collabReceiverTurns.get(input.providerConversationId); + if (direct) { + return direct; + } + + if ( + input.currentProviderThreadId && + input.providerConversationId !== input.currentProviderThreadId && + input.collabReceiverTurns.size === 1 + ) { + return Array.from(input.collabReceiverTurns.values())[0]; + } + + return undefined; +} + +function subagentChildrenFromNotification( + collabReceiverTurns: Map<string, CollabReceiverInfo>, + notification: CodexServerNotification, +): ReadonlyArray<{ + readonly providerThreadId: string; + readonly childThreadId: string; + readonly parentItemId?: string | undefined; + readonly rawPrompt?: string | undefined; + readonly titleSeed?: string | undefined; +}> { + if (notification.method !== "item/started" && notification.method !== "item/completed") { + return []; + } + if (notification.params.item.type !== "collabAgentToolCall") { + return []; + } + return notification.params.item.receiverThreadIds.flatMap((providerThreadId) => { + const info = collabReceiverTurns.get(providerThreadId); + if (!info) { + return []; + } + return [ + { + providerThreadId, + childThreadId: String(info.childThreadId), + ...(info.parentItemId ? { parentItemId: String(info.parentItemId) } : {}), + ...(info.rawPrompt ? { rawPrompt: info.rawPrompt } : {}), + ...(info.detail ? { titleSeed: info.detail } : {}), + }, + ]; + }); } function toCodexUserInputAnswer( @@ -713,7 +815,7 @@ export const makeCodexSessionRuntime = ( const pendingApprovalsRef = yield* Ref.make(new Map<ApprovalRequestId, PendingApproval>()); const approvalCorrelationsRef = yield* Ref.make(new Map<string, ApprovalCorrelation>()); const pendingUserInputsRef = yield* Ref.make(new Map<ApprovalRequestId, PendingUserInput>()); - const collabReceiverTurnsRef = yield* Ref.make(new Map<string, TurnId>()); + const collabReceiverTurnsRef = yield* Ref.make(new Map<string, CollabReceiverInfo>()); const closedRef = yield* Ref.make(false); // `~` is not shell-expanded when env vars are set via @@ -834,22 +936,24 @@ export const makeCodexSessionRuntime = ( const payload = notification.params; const route = readRouteFields(notification); const collabReceiverTurns = yield* Ref.get(collabReceiverTurnsRef); - const childParentTurnId = (() => { - const providerConversationId = readNotificationThreadId(notification); - return providerConversationId - ? collabReceiverTurns.get(providerConversationId) - : undefined; - })(); + const providerConversationId = readNotificationThreadId(notification); + const currentProviderThreadId = yield* currentSessionProviderThreadId; + const childParentInfo = resolveChildParentInfo({ + collabReceiverTurns, + providerConversationId, + currentProviderThreadId, + }); - rememberCollabReceiverTurns(collabReceiverTurns, notification, route.turnId); - if (childParentTurnId && shouldSuppressChildConversationNotification(notification.method)) { - yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); - return; - } + rememberCollabReceiverTurns( + collabReceiverTurns, + notification, + route.turnId, + childParentInfo?.childThreadId ?? options.threadId, + ); let requestId: ApprovalRequestId | undefined; let requestKind: ProviderRequestKind | undefined; - let turnId = childParentTurnId ?? route.turnId; + let turnId = route.turnId; let itemId = route.itemId; if (notification.method === "serverRequest/resolved") { @@ -874,9 +978,20 @@ export const makeCodexSessionRuntime = ( } yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); + const subagentChildren = subagentChildrenFromNotification( + collabReceiverTurns, + notification, + ); + const emittedPayload = + subagentChildren.length > 0 + ? { + ...payload, + subagentChildren, + } + : payload; yield* emitEvent({ kind: "notification", - threadId: options.threadId, + threadId: childParentInfo?.childThreadId ?? options.threadId, method: notification.method, ...(turnId ? { turnId } : {}), ...(itemId ? { itemId } : {}), @@ -885,7 +1000,7 @@ export const makeCodexSessionRuntime = ( ...(notification.method === "item/agentMessage/delta" ? { textDelta: notification.params.delta } : {}), - ...(payload !== undefined ? { payload } : {}), + ...(emittedPayload !== undefined ? { payload: emittedPayload } : {}), }); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 42a692c5394..9722ceab1ec 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -287,7 +287,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), - Layer.provideMerge(GitLayerLive), + Layer.provideMerge(Layer.mergeAll(GitLayerLive, TextGeneration.layer)), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52f25945510..77aedf9de49 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -56,9 +56,16 @@ import { findSidebarProposedPlan, findLatestProposedPlan, deriveWorkLogEntries, + formatElapsed, hasActionableProposedPlan, isLatestTurnSettled, } from "../session-logic"; +import { + formatTerminalSubagentStatusDuration, + LiveSubagentDuration, + subagentStatusToneClass, + type SubagentThreadStatus, +} from "../subagentDisplay"; import { type LegendListRef } from "@legendapp/list/react"; import { buildPendingUserInputAnswers, @@ -117,7 +124,7 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { BotIcon, ChevronDownIcon, SquareIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { RightPanelTabs } from "./RightPanelTabs"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { cn, randomHex } from "~/lib/utils"; @@ -143,7 +150,7 @@ import { useSavedEnvironmentRuntimeStore, } from "../environments/runtime/catalog"; import { reconnectSavedEnvironment } from "../environments/runtime/service"; -import { buildDraftThreadRouteParams } from "../threadRoutes"; +import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -259,6 +266,56 @@ type EnvironmentUnavailableState = { }; type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; +function SubagentControlBar(props: { + title: string; + status: SubagentThreadStatus; + startedAt: string; + completedAt: string | null; + stopping: boolean; + onStop: () => void; +}) { + const statusDuration = + props.status === "running" ? ( + <LiveSubagentDuration startedAt={props.startedAt} /> + ) : ( + formatTerminalSubagentStatusDuration( + props.status, + formatElapsed(props.startedAt, props.completedAt ?? undefined), + ) + ); + + return ( + <div className="rounded-xl border border-border/70 bg-card/55 px-3 py-2 shadow-sm"> + <div className="flex min-w-0 items-center gap-3"> + <span + className={cn( + "flex size-8 shrink-0 items-center justify-center rounded-full border", + subagentStatusToneClass(props.status), + )} + aria-hidden="true" + > + <BotIcon className="size-4" /> + </span> + <div className="min-w-0 flex-1"> + <p className="truncate text-sm font-medium text-foreground">Subagent - {props.title}</p> + <p className="truncate text-xs text-muted-foreground">{statusDuration}</p> + </div> + {props.status === "running" ? ( + <Button + type="button" + size="sm" + variant="outline" + disabled={props.stopping} + onClick={props.onStop} + > + <SquareIcon className="size-3.5" /> + Stop + </Button> + ) : null} + </div> + </div> + ); +} function eventPathContainsSelector(event: Event, selector: string): boolean { const path = event.composedPath(); @@ -1165,6 +1222,9 @@ function ChatViewContent(props: ChatViewProps) { const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); + const [pendingSubagentStopThreadId, setPendingSubagentStopThreadId] = useState<ThreadId | null>( + null, + ); const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record<string, Record<string, PendingUserInputDraftAnswer>> >({}); @@ -1236,6 +1296,30 @@ function ChatViewContent(props: ChatViewProps) { ); const isServerThread = routeKind === "server" && serverThread !== undefined; const activeThread = isServerThread ? serverThread : localDraftThread; + const activeThreadSubagentRelation = + activeThread?.parentRelation?.kind === "subagent" ? activeThread.parentRelation : null; + const activeThreadParentRef = + activeThread && activeThreadSubagentRelation + ? scopeThreadRef(activeThread.environmentId, activeThreadSubagentRelation.parentThreadId) + : null; + const openActiveThreadParent = useCallback(() => { + if (!activeThreadParentRef) { + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(activeThreadParentRef), + }); + }, [activeThreadParentRef, navigate]); + useEffect(() => { + if ( + pendingSubagentStopThreadId !== null && + (activeThread?.id !== pendingSubagentStopThreadId || + activeThreadSubagentRelation?.status !== "running") + ) { + setPendingSubagentStopThreadId(null); + } + }, [activeThread?.id, activeThreadSubagentRelation?.status, pendingSubagentStopThreadId]); const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; @@ -4067,12 +4151,18 @@ function ChatViewContent(props: ChatViewProps) { const onInterrupt = async () => { const api = readEnvironmentApi(environmentId); if (!api || !activeThread) return; - await api.orchestration.dispatchCommand({ - type: "thread.turn.interrupt", - commandId: newCommandId(), - threadId: activeThread.id, - createdAt: new Date().toISOString(), - }); + setPendingSubagentStopThreadId(activeThread.id); + try { + await api.orchestration.dispatchCommand({ + type: "thread.turn.interrupt", + commandId: newCommandId(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + }); + } catch (error) { + setPendingSubagentStopThreadId(null); + throw error; + } }; const onRespondToApproval = useCallback( @@ -4826,6 +4916,7 @@ function ChatViewContent(props: ChatViewProps) { onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} + {...(activeThreadParentRef ? { onOpenParentThread: openActiveThreadParent } : {})} rightPanelOpen={rightPanelOpen} /> </header> @@ -4894,79 +4985,90 @@ function ChatViewContent(props: ChatViewProps) { <div className="relative isolate"> <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> <div className="relative z-10"> - <ChatComposer - composerRef={composerRef} - composerDraftTarget={composerDraftTarget} - environmentId={environmentId} - routeKind={routeKind} - routeThreadRef={routeThreadRef} - draftId={draftId} - activeThreadId={activeThreadId} - activeThreadEnvironmentId={activeThread?.environmentId} - activeThread={activeThread} - isServerThread={isServerThread} - isLocalDraftThread={isLocalDraftThread} - phase={phase} - isConnecting={isConnecting} - isSendBusy={isSendBusy} - isPreparingWorktree={isPreparingWorktree} - environmentUnavailable={activeEnvironmentUnavailableState} - activePendingApproval={activePendingApproval} - pendingApprovals={pendingApprovals} - pendingUserInputs={pendingUserInputs} - activePendingProgress={activePendingProgress} - activePendingResolvedAnswers={activePendingResolvedAnswers} - activePendingIsResponding={activePendingIsResponding} - activePendingDraftAnswers={activePendingDraftAnswers} - activePendingQuestionIndex={activePendingQuestionIndex} - respondingRequestIds={respondingRequestIds} - showPlanFollowUpPrompt={showPlanFollowUpPrompt} - activeProposedPlan={activeProposedPlan} - activePlan={activePlan as { turnId?: TurnId } | null} - sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} - planSidebarLabel={planSidebarLabel} - planSidebarOpen={planSidebarOpen} - runtimeMode={runtimeMode} - interactionMode={interactionMode} - lockedProvider={lockedProvider} - providerStatuses={providerStatuses as ServerProvider[]} - activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} - activeThreadModelSelection={activeThread?.modelSelection} - activeThreadActivities={activeThread?.activities} - resolvedTheme={resolvedTheme} - settings={settings} - keybindings={keybindings} - terminalOpen={Boolean(terminalUiState.terminalOpen)} - gitCwd={gitCwd} - promptRef={promptRef} - composerImagesRef={composerImagesRef} - composerTerminalContextsRef={composerTerminalContextsRef} - composerElementContextsRef={composerElementContextsRef} - shouldAutoScrollRef={isAtEndRef} - scheduleStickToBottom={scrollToEnd} - onSend={onSend} - onInterrupt={onInterrupt} - onImplementPlanInNewThread={onImplementPlanInNewThread} - onRespondToApproval={onRespondToApproval} - onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} - onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} - onPreviousActivePendingUserInputQuestion={ - onPreviousActivePendingUserInputQuestion - } - onChangeActivePendingUserInputCustomAnswer={ - onChangeActivePendingUserInputCustomAnswer - } - onProviderModelSelect={onProviderModelSelect} - getModelDisabledReason={getModelDisabledReason} - toggleInteractionMode={toggleInteractionMode} - handleRuntimeModeChange={handleRuntimeModeChange} - handleInteractionModeChange={handleInteractionModeChange} - togglePlanSidebar={togglePlanSidebar} - focusComposer={focusComposer} - scheduleComposerFocus={scheduleComposerFocus} - setThreadError={setThreadError} - onExpandImage={onExpandTimelineImage} - /> + {activeThreadSubagentRelation ? ( + <SubagentControlBar + title={activeThread.title} + status={activeThreadSubagentRelation.status} + startedAt={activeThreadSubagentRelation.startedAt} + completedAt={activeThreadSubagentRelation.completedAt} + stopping={isConnecting || pendingSubagentStopThreadId === activeThread.id} + onStop={onInterrupt} + /> + ) : ( + <ChatComposer + composerRef={composerRef} + composerDraftTarget={composerDraftTarget} + environmentId={environmentId} + routeKind={routeKind} + routeThreadRef={routeThreadRef} + draftId={draftId} + activeThreadId={activeThreadId} + activeThreadEnvironmentId={activeThread?.environmentId} + activeThread={activeThread} + isServerThread={isServerThread} + isLocalDraftThread={isLocalDraftThread} + phase={phase} + isConnecting={isConnecting} + isSendBusy={isSendBusy} + isPreparingWorktree={isPreparingWorktree} + environmentUnavailable={activeEnvironmentUnavailableState} + activePendingApproval={activePendingApproval} + pendingApprovals={pendingApprovals} + pendingUserInputs={pendingUserInputs} + activePendingProgress={activePendingProgress} + activePendingResolvedAnswers={activePendingResolvedAnswers} + activePendingIsResponding={activePendingIsResponding} + activePendingDraftAnswers={activePendingDraftAnswers} + activePendingQuestionIndex={activePendingQuestionIndex} + respondingRequestIds={respondingRequestIds} + showPlanFollowUpPrompt={showPlanFollowUpPrompt} + activeProposedPlan={activeProposedPlan} + activePlan={activePlan as { turnId?: TurnId } | null} + sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} + planSidebarLabel={planSidebarLabel} + planSidebarOpen={planSidebarOpen} + runtimeMode={runtimeMode} + interactionMode={interactionMode} + lockedProvider={lockedProvider} + providerStatuses={providerStatuses as ServerProvider[]} + activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} + activeThreadModelSelection={activeThread?.modelSelection} + activeThreadActivities={activeThread?.activities} + resolvedTheme={resolvedTheme} + settings={settings} + keybindings={keybindings} + terminalOpen={Boolean(terminalUiState.terminalOpen)} + gitCwd={gitCwd} + promptRef={promptRef} + composerImagesRef={composerImagesRef} + composerTerminalContextsRef={composerTerminalContextsRef} + composerElementContextsRef={composerElementContextsRef} + shouldAutoScrollRef={isAtEndRef} + scheduleStickToBottom={scrollToEnd} + onSend={onSend} + onInterrupt={onInterrupt} + onImplementPlanInNewThread={onImplementPlanInNewThread} + onRespondToApproval={onRespondToApproval} + onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} + onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} + onPreviousActivePendingUserInputQuestion={ + onPreviousActivePendingUserInputQuestion + } + onChangeActivePendingUserInputCustomAnswer={ + onChangeActivePendingUserInputCustomAnswer + } + onProviderModelSelect={onProviderModelSelect} + getModelDisabledReason={getModelDisabledReason} + toggleInteractionMode={toggleInteractionMode} + handleRuntimeModeChange={handleRuntimeModeChange} + handleInteractionModeChange={handleInteractionModeChange} + togglePlanSidebar={togglePlanSidebar} + focusComposer={focusComposer} + scheduleComposerFocus={scheduleComposerFocus} + setThreadError={setThreadError} + onExpandImage={onExpandTimelineImage} + /> + )} </div> </div> {isGitRepo && ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9e6ff1c34cb..ff0d549ac99 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -70,7 +70,8 @@ import { selectProjectByRef, selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRefs, - selectSidebarThreadsAcrossEnvironments, + selectSidebarThreadsAcrossEnvironmentsForRoute, + selectSidebarThreadsForProjectRefsForRoute, selectThreadByRef, useStore, } from "../store"; @@ -212,6 +213,119 @@ const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = { updated_at: "Last user message", created_at: "Created at", }; + +interface RenderedSidebarThread { + thread: SidebarThreadSummary; + depth: number; +} + +function sidebarThreadKey(thread: Pick<SidebarThreadSummary, "environmentId" | "id">): string { + return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); +} + +function sidebarThreadParentKey(thread: SidebarThreadSummary): string | null { + const relation = thread.parentRelation; + if (relation?.kind !== "subagent") { + return null; + } + return scopedThreadKey(scopeThreadRef(thread.environmentId, relation.parentThreadId)); +} + +function compareSubagentSidebarChildren( + left: SidebarThreadSummary, + right: SidebarThreadSummary, +): number { + const leftRelation = left.parentRelation?.kind === "subagent" ? left.parentRelation : null; + const rightRelation = right.parentRelation?.kind === "subagent" ? right.parentRelation : null; + const sequence = + (leftRelation?.parentActivitySequence ?? 0) - (rightRelation?.parentActivitySequence ?? 0); + if (sequence !== 0) { + return sequence; + } + const startedAt = (leftRelation?.startedAt ?? left.createdAt).localeCompare( + rightRelation?.startedAt ?? right.createdAt, + ); + if (startedAt !== 0) { + return startedAt; + } + return sidebarThreadKey(left).localeCompare(sidebarThreadKey(right)); +} + +function flattenSidebarThreadTree(input: { + allThreads: readonly SidebarThreadSummary[]; + roots: readonly SidebarThreadSummary[]; +}): RenderedSidebarThread[] { + const allThreadKeys = new Set(input.allThreads.map(sidebarThreadKey)); + const childrenByParentKey = new Map<string, SidebarThreadSummary[]>(); + for (const thread of input.allThreads) { + const parentKey = sidebarThreadParentKey(thread); + if (!parentKey || !allThreadKeys.has(parentKey)) { + continue; + } + const children = childrenByParentKey.get(parentKey); + if (children) { + children.push(thread); + } else { + childrenByParentKey.set(parentKey, [thread]); + } + } + for (const children of childrenByParentKey.values()) { + children.sort(compareSubagentSidebarChildren); + } + + const result: RenderedSidebarThread[] = []; + const visited = new Set<string>(); + const visit = (thread: SidebarThreadSummary, depth: number) => { + const key = sidebarThreadKey(thread); + if (visited.has(key)) { + return; + } + visited.add(key); + result.push({ thread, depth }); + for (const child of childrenByParentKey.get(key) ?? []) { + visit(child, depth + 1); + } + }; + for (const root of input.roots) { + visit(root, 0); + } + return result; +} + +function resolveSidebarRootThread( + threads: readonly SidebarThreadSummary[], + threadKey: string, + threadByKey = new Map(threads.map((thread) => [sidebarThreadKey(thread), thread] as const)), +): SidebarThreadSummary | null { + let current = threadByKey.get(threadKey) ?? null; + const seen = new Set<string>(); + while (current) { + const currentKey = sidebarThreadKey(current); + if (seen.has(currentKey)) { + return current; + } + seen.add(currentKey); + const parentKey = sidebarThreadParentKey(current); + if (!parentKey) { + return current; + } + const parent = threadByKey.get(parentKey); + if (!parent) { + return current; + } + current = parent; + } + return null; +} + +function rootSidebarThreads(threads: readonly SidebarThreadSummary[]): SidebarThreadSummary[] { + const keys = new Set(threads.map(sidebarThreadKey)); + return threads.filter((thread) => { + const parentKey = sidebarThreadParentKey(thread); + return !parentKey || !keys.has(parentKey); + }); +} + const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", @@ -286,6 +400,7 @@ function buildThreadJumpLabelMap(input: { interface SidebarThreadRowProps { thread: SidebarThreadSummary; + depth?: number; projectCwd: string | null; orderedProjectThreadKeys: readonly string[]; isActive: boolean; @@ -347,6 +462,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr attemptArchiveThread, openPrLink, thread, + depth = 0, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); @@ -392,6 +508,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const isHighlighted = isActive || isSelected; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; + const canArchiveThread = thread.parentRelation?.kind !== "subagent"; const threadStatus = resolveThreadStatusPill({ thread: { ...thread, @@ -401,7 +518,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; + const isConfirmingArchive = + canArchiveThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" : !isThreadRunning @@ -591,6 +709,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr data-thread-item onMouseLeave={handleMouseLeave} onBlurCapture={handleBlurCapture} + style={depth > 0 ? { paddingLeft: `${Math.min(depth, 6) * 0.75}rem` } : undefined} > <SidebarMenuSubButton render={rowButtonRender} @@ -709,7 +828,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr > Confirm </button> - ) : !isThreadRunning ? ( + ) : canArchiveThread && !isThreadRunning ? ( appSettingsConfirmThreadArchive ? ( <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100"> <button @@ -806,7 +925,7 @@ interface SidebarProjectThreadListProps { hasOverflowingThreads: boolean; hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; + renderedThreads: readonly RenderedSidebarThread[]; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; @@ -906,12 +1025,13 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( </SidebarMenuSubItem> ) : null} {shouldShowThreadPanel && - renderedThreads.map((thread) => { + renderedThreads.map(({ thread, depth }) => { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); return ( <SidebarThreadRow key={threadKey} thread={thread} + depth={depth} projectCwd={projectCwd} orderedProjectThreadKeys={orderedProjectThreadKeys} isActive={activeRouteThreadKey === threadKey} @@ -1103,12 +1223,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); }); }, []); + const activeRouteThreadRef = useMemo( + () => (activeRouteThreadKey ? parseScopedThreadKey(activeRouteThreadKey) : null), + [activeRouteThreadKey], + ); const sidebarThreads = useStore( useShallow( useMemo( () => (state: import("../store").AppState) => - selectSidebarThreadsForProjectRefs(state, project.memberProjectRefs), - [project.memberProjectRefs], + selectSidebarThreadsForProjectRefsForRoute( + state, + project.memberProjectRefs, + activeRouteThreadRef, + ), + [activeRouteThreadRef, project.memberProjectRefs], ), ), ); @@ -1182,7 +1310,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return counts; }, [memberProjectByScopedKey, project.memberProjects, projectThreads]); - const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { + const { + projectStatus, + visibleProjectThreads, + visibleRootProjectThreads, + orderedProjectThreadKeys, + } = useMemo(() => { const lastVisitedAtByThreadKey = new Map( projectThreads.map((thread, index) => [ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), @@ -1204,15 +1337,19 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectThreads.filter((thread) => thread.archivedAt === null), threadSortOrder, ); + const visibleRootProjectThreads = rootSidebarThreads(visibleProjectThreads); + const orderedProjectThreadKeys = flattenSidebarThreadTree({ + allThreads: visibleProjectThreads, + roots: visibleRootProjectThreads, + }).map(({ thread }) => sidebarThreadKey(thread)); const projectStatus = resolveProjectStatusIndicator( visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)), ); return { - orderedProjectThreadKeys: visibleProjectThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), + orderedProjectThreadKeys, projectStatus, visibleProjectThreads, + visibleRootProjectThreads, }; }, [projectThreads, threadLastVisitedAts, threadSortOrder]); @@ -1221,12 +1358,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (!activeThreadKey || projectExpanded) { return null; } - return ( - visibleProjectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, - ) ?? null - ); + return resolveSidebarRootThread(visibleProjectThreads, activeThreadKey); }, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]); const { @@ -1253,24 +1385,48 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; - const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount; + const hasOverflowingThreads = visibleRootProjectThreads.length > sidebarThreadPreviewCount; const previewThreads = isThreadListExpanded || !hasOverflowingThreads - ? visibleProjectThreads - : visibleProjectThreads.slice(0, sidebarThreadPreviewCount); + ? visibleRootProjectThreads + : visibleRootProjectThreads.slice(0, sidebarThreadPreviewCount); + const activeRouteRootThread = activeRouteThreadKey + ? resolveSidebarRootThread(visibleProjectThreads, activeRouteThreadKey) + : null; const visibleThreadKeys = new Set( - [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), + [ + ...previewThreads, + ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : []), + ...(activeRouteRootThread ? [activeRouteRootThread] : []), + ].map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), ); - const renderedThreads = pinnedCollapsedThread + const renderedRoots = pinnedCollapsedThread ? [pinnedCollapsedThread] - : visibleProjectThreads.filter((thread) => - visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + : visibleRootProjectThreads.filter((thread) => + visibleThreadKeys.has(sidebarThreadKey(thread)), ); - const hiddenThreads = visibleProjectThreads.filter( - (thread) => - !visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + const renderedThreads = flattenSidebarThreadTree({ + allThreads: visibleProjectThreads, + roots: renderedRoots, + }); + const hiddenRootKeys = new Set( + visibleRootProjectThreads + .filter((thread) => !visibleThreadKeys.has(sidebarThreadKey(thread))) + .map(sidebarThreadKey), + ); + const visibleThreadByKey = new Map( + visibleProjectThreads.map((thread) => [sidebarThreadKey(thread), thread] as const), + ); + const hiddenThreads = visibleProjectThreads.filter((thread) => + hiddenRootKeys.has( + sidebarThreadKey( + resolveSidebarRootThread( + visibleProjectThreads, + sidebarThreadKey(thread), + visibleThreadByKey, + ) ?? thread, + ), + ), ); return { hasOverflowingThreads, @@ -1278,10 +1434,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), ), renderedThreads, - showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0, + showEmptyThreadState: projectExpanded && visibleRootProjectThreads.length === 0, shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null, }; }, [ + activeRouteThreadKey, isThreadListExpanded, pinnedCollapsedThread, projectExpanded, @@ -1289,6 +1446,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec sidebarThreadPreviewCount, threadLastVisitedAts, visibleProjectThreads, + visibleRootProjectThreads, ]); const handleProjectButtonClick = useCallback( @@ -1694,11 +1852,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; if (threadKeys.length === 0) return; const count = threadKeys.length; + const canDeleteSelection = threadKeys.every( + (threadKey) => + sidebarThreadByKeyRef.current.get(threadKey)?.parentRelation?.kind !== "subagent", + ); const clicked = await api.contextMenu.show( [ { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, + ...(canDeleteSelection + ? [{ id: "delete", label: `Delete (${count})`, destructive: true } as const] + : []), ], position, ); @@ -2011,13 +2175,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), ); const threadWorkspacePath = thread.worktreePath ?? threadProject?.cwd ?? project.cwd ?? null; + const isSubagentThread = thread.parentRelation?.kind === "subagent"; const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, - { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ...(!isSubagentThread + ? [{ id: "delete", label: "Delete", destructive: true, icon: "trash" } as const] + : []), ], position, ); @@ -2894,7 +3061,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); @@ -2915,6 +3081,15 @@ export default function Sidebar() { select: (params) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const sidebarThreads = useStore( + useShallow( + useMemo( + () => (state: import("../store").AppState) => + selectSidebarThreadsAcrossEnvironmentsForRoute(state, routeThreadRef), + [routeThreadRef], + ), + ), + ); const keybindings = useServerKeybindings(); const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< @@ -3144,12 +3319,13 @@ export default function Sidebar() { () => sidebarThreads.filter((thread) => thread.archivedAt === null), [sidebarThreads], ); + const visibleRootThreads = useMemo(() => rootSidebarThreads(visibleThreads), [visibleThreads]); const sortedProjects = useMemo(() => { const sortableProjects = sidebarProjects.map((project) => ({ ...project, id: project.projectKey, })); - const sortableThreads = visibleThreads.map((thread) => { + const sortableThreads = visibleRootThreads.map((thread) => { const physicalKey = projectPhysicalKeyByScopedRef.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), @@ -3173,7 +3349,7 @@ export default function Sidebar() { projectPhysicalKeyByScopedRef, sidebarProjectByKey, sidebarProjects, - visibleThreads, + visibleRootThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; const visibleSidebarThreadKeys = useMemo( @@ -3185,30 +3361,39 @@ export default function Sidebar() { ), sidebarThreadSortOrder, ); + const rootProjectThreads = rootSidebarThreads(projectThreads); const projectExpanded = projectExpandedById[project.projectKey] ?? true; const activeThreadKey = routeThreadKey ?? undefined; const pinnedCollapsedThread = !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) + ? resolveSidebarRootThread(projectThreads, activeThreadKey) : null; const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; if (!shouldShowThreadPanel) { return []; } const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; + const hasOverflowingThreads = rootProjectThreads.length > sidebarThreadPreviewCount; const previewThreads = isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, sidebarThreadPreviewCount); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ? rootProjectThreads + : rootProjectThreads.slice(0, sidebarThreadPreviewCount); + const activeRouteRootThread = activeThreadKey + ? resolveSidebarRootThread(projectThreads, activeThreadKey) + : null; + const renderedRootKeys = new Set( + [ + ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads), + ...(activeRouteRootThread ? [activeRouteRootThread] : []), + ].map(sidebarThreadKey), ); + const renderedRoots = pinnedCollapsedThread + ? [pinnedCollapsedThread, ...(activeRouteRootThread ? [activeRouteRootThread] : [])] + : rootProjectThreads.filter((thread) => renderedRootKeys.has(sidebarThreadKey(thread))); + return flattenSidebarThreadTree({ + allThreads: projectThreads, + roots: renderedRoots, + }).map(({ thread }) => sidebarThreadKey(thread)); }), [ sidebarThreadSortOrder, diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 2bfc204cec7..f58f301093e 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -9,6 +9,8 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; +import { CornerLeftUpIcon } from "lucide-react"; +import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { SidebarTrigger } from "../ui/sidebar"; @@ -32,6 +34,7 @@ interface ChatHeaderProps { onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; onDeleteProjectScript: (scriptId: string) => Promise<void>; + onOpenParentThread?: (() => void) | undefined; rightPanelOpen: boolean; } @@ -63,6 +66,7 @@ export const ChatHeader = memo(function ChatHeader({ onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, + onOpenParentThread, rightPanelOpen, }: ChatHeaderProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -75,19 +79,40 @@ export const ChatHeader = memo(function ChatHeader({ <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"> <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3"> <SidebarTrigger className="size-7 shrink-0 md:hidden" /> - <Tooltip> - <TooltipTrigger - render={ - <h2 - aria-label={activeThreadTitle} - className="min-w-0 flex-1 truncate text-sm font-medium text-foreground" + <div className="flex min-w-0 flex-1 items-center gap-1.5"> + <Tooltip> + <TooltipTrigger + render={ + <h2 + aria-label={activeThreadTitle} + className="min-w-0 flex-1 truncate text-sm font-medium text-foreground" + > + {activeThreadTitle} + </h2> + } + /> + <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup> + </Tooltip> + {onOpenParentThread && ( + <Tooltip> + <TooltipTrigger + render={ + <Button + type="button" + size="icon-xs" + variant="outline" + className="shrink-0" + aria-label="Open parent conversation" + onClick={onOpenParentThread} + /> + } > - {activeThreadTitle} - </h2> - } - /> - <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup> - </Tooltip> + <CornerLeftUpIcon className="size-3.5" /> + </TooltipTrigger> + <TooltipPopup side="bottom">Open parent conversation</TooltipPopup> + </Tooltip> + )} + </div> </div> <div data-chat-header-actions diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f7da222f441..55007c1873e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -83,6 +83,8 @@ beforeAll(() => { documentElement: { classList, offsetHeight: 0, + removeAttribute: () => {}, + setAttribute: () => {}, }, }); }); @@ -358,4 +360,36 @@ describe("MessagesTimeline", () => { expect(markup).toContain("lucide-x"); expect(markup).toContain('aria-label="Tool call failed"'); }); + + it("renders expandable subagent rows without status labels", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + <MessagesTimeline + {...buildProps()} + timelineEntries={[ + { + id: "entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Subagent", + tone: "tool", + itemType: "collab_agent_tool_call", + subagentPrompt: "Create one original haiku in English. Return only the haiku text.", + output: + "Rain lifts from the wires\nA window gathers pale dawn\nFootsteps bloom below", + }, + }, + ]} + />, + ); + + expect(markup).toContain("Subagent"); + expect(markup).toContain("Create one original haiku in English"); + expect(markup).not.toContain("Done"); + expect(markup).not.toContain("Running"); + expect(markup).toContain('aria-label="Subagent - Create one original haiku'); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index d340f7ac7ca..f281692d410 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3,9 +3,11 @@ import { type MessageId, type ScopedThreadRef, type ServerProviderSkill, + type ThreadId, type TurnId, } from "@t3tools/contracts"; -import { parseScopedThreadKey } from "@t3tools/client-runtime"; +import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { useNavigate } from "@tanstack/react-router"; import { createContext, Fragment, @@ -30,6 +32,12 @@ import { workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; +import { + formatSubagentDuration, + formatTerminalSubagentStatusDuration, + LiveSubagentDuration, + subagentStatusToneClass, +} from "../../subagentDisplay"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import { getRenderablePatch, @@ -92,6 +100,8 @@ import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import { useStore } from "../../store"; import { buildInlineTerminalContextText, @@ -1445,10 +1455,25 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { } function workEntryPreview( - workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">, + workEntry: Pick< + TimelineWorkEntry, + | "detail" + | "command" + | "changedFiles" + | "itemType" + | "output" + | "subagentPrompt" + | "subagentChildren" + >, workspaceRoot: string | undefined, ) { if (workEntry.command) return workEntry.command; + if ((workEntry.subagentChildren?.length ?? 0) > 0) return null; + if (workEntry.itemType === "collab_agent_tool_call") { + const { prompt, output } = resolveSubagentDisplayParts(workEntry); + return prompt ?? output; + } + if (workEntry.subagentPrompt) return workEntry.subagentPrompt; if (workEntry.detail) return workEntry.detail; if ((workEntry.changedFiles?.length ?? 0) === 0) return null; const [firstPath] = workEntry.changedFiles ?? []; @@ -1474,6 +1499,15 @@ function buildToolCallExpandedBody( workspaceRoot: string | undefined, ): string | null { const blocks: string[] = []; + if (workEntry.itemType === "collab_agent_tool_call") { + const { prompt, output } = resolveSubagentDisplayParts(workEntry); + if (prompt) { + blocks.push(`Prompt\n${prompt}`); + } + if (output) { + blocks.push(`Output\n${output}`); + } + } if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); } @@ -1543,6 +1577,38 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); } +function normalizedSubagentText(value: string | undefined): string { + return (value ?? "").replace(/\s+/g, " ").trim(); +} + +function resolveSubagentDisplayParts( + workEntry: Pick<TimelineWorkEntry, "output" | "subagentPrompt">, +): { + prompt: string | null; + output: string | null; +} { + const prompt = workEntry.subagentPrompt?.trim() ?? ""; + const output = workEntry.output?.trim() ?? ""; + if (!prompt) { + return { prompt: null, output: output || null }; + } + if (!output) { + return { prompt, output: null }; + } + + const normalizedPrompt = normalizedSubagentText(prompt).toLowerCase(); + const normalizedOutput = normalizedSubagentText(output).toLowerCase(); + const redundantPrompt = + normalizedPrompt === normalizedOutput || + normalizedPrompt.startsWith(normalizedOutput) || + normalizedOutput.startsWith(normalizedPrompt); + + return { + prompt: redundantPrompt ? null : prompt, + output, + }; +} + const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { @@ -1552,6 +1618,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const { workEntry, workspaceRoot } = props; const activity = use(TimelineRowActivityCtx); const [expanded, setExpanded] = useState(false); + if (workEntry.itemType === "collab_agent_tool_call" && workEntry.subagentChildren?.length) { + return <SubagentWorkEntryRows workEntry={workEntry} />; + } const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); @@ -1703,3 +1772,89 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { </div> ); }); + +const SubagentWorkEntryRows = memo(function SubagentWorkEntryRows({ + workEntry, +}: { + workEntry: TimelineWorkEntry; +}) { + return ( + <div className="space-y-1 py-0.5"> + {workEntry.subagentChildren?.map((child) => ( + <SubagentWorkEntryButton + key={`${workEntry.id}:subagent:${child.threadId}`} + parentCreatedAt={workEntry.createdAt} + threadId={child.threadId} + {...((child.titleSeed ?? workEntry.subagentPrompt ?? workEntry.detail) + ? { titleSeed: child.titleSeed ?? workEntry.subagentPrompt ?? workEntry.detail } + : {})} + /> + ))} + </div> + ); +}); + +const SubagentWorkEntryButton = memo(function SubagentWorkEntryButton(props: { + parentCreatedAt: string; + threadId: ThreadId; + titleSeed?: string; +}) { + const ctx = use(TimelineRowCtx); + const navigate = useNavigate(); + const childShell = useStore( + useCallback( + (state) => + state.environmentStateById[ctx.activeThreadEnvironmentId]?.threadShellById[props.threadId], + [ctx.activeThreadEnvironmentId, props.threadId], + ), + ); + const relation = + childShell?.parentRelation?.kind === "subagent" ? childShell.parentRelation : null; + const rawTitle = childShell?.title?.trim(); + const title = rawTitle && rawTitle !== "Subagent" ? rawTitle : null; + const displayTitle = title ? `Subagent - ${title}` : "Subagent"; + const status = relation?.status ?? null; + const startedAt = relation?.startedAt ?? props.parentCreatedAt; + const completedAt = relation?.completedAt ?? null; + const statusDurationLabel = + status === "running" ? ( + <LiveSubagentDuration startedAt={startedAt} /> + ) : ( + formatTerminalSubagentStatusDuration(status, formatSubagentDuration(startedAt, completedAt)) + ); + + const openChildThread = useCallback(() => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(ctx.activeThreadEnvironmentId, props.threadId)), + }); + }, [ctx.activeThreadEnvironmentId, navigate, props.threadId]); + + return ( + <button + type="button" + className="group flex w-full items-center gap-2 rounded-md px-1 py-1 text-left transition-colors hover:bg-background/55 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + onClick={openChildThread} + title={`Open ${displayTitle}`} + > + <span + className={cn( + "flex size-5 shrink-0 items-center justify-center rounded-full border", + subagentStatusToneClass(status), + )} + aria-hidden="true" + > + <BotIcon className="size-3" /> + </span> + <span className="min-w-0 flex-1"> + <span className="block truncate text-xs font-medium text-foreground/82"> + {displayTitle} + </span> + <span className="block truncate text-[10px] text-muted-foreground/62"> + {statusDurationLabel} + </span> + </span> + <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/45 transition-colors group-hover:text-foreground/75" /> + </button> + ); +}); diff --git a/apps/web/src/environments/runtime/service.coalescing.test.ts b/apps/web/src/environments/runtime/service.coalescing.test.ts new file mode 100644 index 00000000000..89d2b1937c6 --- /dev/null +++ b/apps/web/src/environments/runtime/service.coalescing.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ThreadId, TurnId, type OrchestrationEvent } from "@t3tools/contracts"; + +import { coalesceOrchestrationUiEvents } from "./service"; + +function makeSubagentOutputEvent(params: { + readonly eventId: string; + readonly activityId: string; + readonly content: string; + readonly createdAt: string; + readonly sequence: number; +}): Extract<OrchestrationEvent, { type: "thread.activity-appended" }> { + return { + eventId: params.eventId as OrchestrationEvent["eventId"], + type: "thread.activity-appended", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + sequence: params.sequence, + occurredAt: params.createdAt, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + activity: { + id: params.activityId as OrchestrationEvent["eventId"], + tone: "tool", + kind: "tool.updated", + summary: "Subagent", + turnId: TurnId.make("turn-1"), + sequence: params.sequence, + createdAt: params.createdAt, + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + detail: "Create a haiku", + data: { + toolCallId: "collab-1", + rawOutput: { + content: params.content, + }, + }, + }, + }, + }, + }; +} + +describe("coalesceOrchestrationUiEvents", () => { + it("coalesces adjacent subagent output chunks for the same tool call", () => { + const first = makeSubagentOutputEvent({ + eventId: "event-1", + activityId: "activity-1", + content: "Rain lifts ", + createdAt: "2026-05-22T12:00:00.000Z", + sequence: 1, + }); + const second = makeSubagentOutputEvent({ + eventId: "event-2", + activityId: "activity-2", + content: "from wires", + createdAt: "2026-05-22T12:00:00.016Z", + sequence: 2, + }); + + const coalesced = coalesceOrchestrationUiEvents([first, second]); + + expect(coalesced).toHaveLength(1); + expect(coalesced[0]?.eventId).toBe("event-2"); + expect(coalesced[0]).toMatchObject({ + payload: { + activity: { + createdAt: "2026-05-22T12:00:00.000Z", + sequence: 1, + payload: { + data: { + rawOutput: { + content: "Rain lifts from wires", + }, + }, + }, + }, + }, + }); + }); +}); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index cbd0c996199..2487be4fd46 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -112,8 +112,14 @@ type ThreadDetailSubscriptionEntry = { refCount: number; lastAccessedAt: number; evictionTimeoutId: ReturnType<typeof setTimeout> | null; + pendingEvents: OrchestrationEvent[]; + pendingFlushHandle: ThreadDetailFlushHandle | null; }; +type ThreadDetailFlushHandle = + | { readonly kind: "animation-frame"; readonly id: number } + | { readonly kind: "timeout"; readonly id: ReturnType<typeof setTimeout> }; + const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); @@ -410,15 +416,70 @@ function attachThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): b { threadId: entry.threadId }, (item) => { if (item.kind === "snapshot") { + flushThreadDetailSubscriptionEvents(entry); useStore.getState().syncServerThreadDetail(item.snapshot.thread, entry.environmentId); return; } - applyEnvironmentThreadDetailEvent(item.event, entry.environmentId); + enqueueThreadDetailSubscriptionEvent(entry, item.event); }, ); return true; } +function enqueueThreadDetailSubscriptionEvent( + entry: ThreadDetailSubscriptionEntry, + event: OrchestrationEvent, +): void { + entry.pendingEvents.push(event); + if (entry.pendingFlushHandle !== null) { + return; + } + + entry.pendingFlushHandle = requestThreadDetailEventFlush(() => { + entry.pendingFlushHandle = null; + flushThreadDetailSubscriptionEvents(entry); + }); +} + +function flushThreadDetailSubscriptionEvents(entry: ThreadDetailSubscriptionEntry): void { + if (entry.pendingFlushHandle !== null) { + cancelThreadDetailEventFlush(entry.pendingFlushHandle); + entry.pendingFlushHandle = null; + } + if (entry.pendingEvents.length === 0) { + return; + } + + const events = entry.pendingEvents; + entry.pendingEvents = []; + applyRecoveredEventBatch(events, entry.environmentId); +} + +function requestThreadDetailEventFlush(callback: () => void): ThreadDetailFlushHandle { + if (typeof requestAnimationFrame === "function") { + return { + kind: "animation-frame", + id: requestAnimationFrame(callback), + }; + } + + return { + kind: "timeout", + id: setTimeout(callback, 16), + }; +} + +function cancelThreadDetailEventFlush(handle: ThreadDetailFlushHandle): void { + if (handle.kind === "animation-frame") { + if (typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(handle.id); + } + return; + } + + clearTimeout(handle.id); +} + function watchThreadDetailSubscriptionConnection(entry: ThreadDetailSubscriptionEntry): void { if (entry.unsubscribeConnectionListener !== null) { return; @@ -442,6 +503,7 @@ function disposeThreadDetailSubscriptionByKey(key: string): boolean { entry.unsubscribeConnectionListener?.(); entry.unsubscribeConnectionListener = null; threadDetailSubscriptions.delete(key); + flushThreadDetailSubscriptionEvents(entry); entry.unsubscribe(); entry.unsubscribe = NOOP; return true; @@ -460,6 +522,7 @@ function detachThreadDetailSubscriptionsForEnvironment(environmentId: Environmen if (entry.environmentId !== environmentId) { continue; } + flushThreadDetailSubscriptionEvents(entry); entry.unsubscribe(); entry.unsubscribe = NOOP; watchThreadDetailSubscriptionConnection(entry); @@ -599,6 +662,8 @@ export function retainThreadDetailSubscription( refCount: 1, lastAccessedAt: Date.now(), evictionTimeoutId: null, + pendingEvents: [], + pendingFlushHandle: null, }; threadDetailSubscriptions.set(key, entry); if (!attachThreadDetailSubscription(entry)) { @@ -910,7 +975,104 @@ function setRuntimeError(environmentId: EnvironmentId, error: unknown) { }); } -function coalesceOrchestrationUiEvents( +type SubagentOutputChunk = { + readonly toolCallId: string; + readonly content: string; +}; + +function asObjectRecord(value: unknown): Record<string, unknown> | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + + return value as Record<string, unknown>; +} + +function asNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function extractSubagentOutputChunk(payload: unknown): SubagentOutputChunk | null { + const payloadRecord = asObjectRecord(payload); + if (payloadRecord?.itemType !== "collab_agent_tool_call") { + return null; + } + + const data = asObjectRecord(payloadRecord.data); + const rawOutput = asObjectRecord(data?.rawOutput); + const content = typeof rawOutput?.content === "string" ? rawOutput.content : null; + if (content === null || content.length === 0) { + return null; + } + + const parentCollab = asObjectRecord(data?.parentCollab); + const toolCallId = asNonEmptyString(data?.toolCallId) ?? asNonEmptyString(parentCollab?.itemId); + if (!toolCallId) { + return null; + } + + return { toolCallId, content }; +} + +function coalesceSubagentOutputEvent( + previous: Extract<OrchestrationEvent, { type: "thread.activity-appended" }>, + event: Extract<OrchestrationEvent, { type: "thread.activity-appended" }>, +): OrchestrationEvent | null { + const previousActivity = previous.payload.activity; + const activity = event.payload.activity; + if ( + previous.payload.threadId !== event.payload.threadId || + previousActivity.kind !== "tool.updated" || + activity.kind !== "tool.updated" || + previousActivity.turnId !== activity.turnId + ) { + return null; + } + + const previousChunk = extractSubagentOutputChunk(previousActivity.payload); + const chunk = extractSubagentOutputChunk(activity.payload); + if (!previousChunk || !chunk || previousChunk.toolCallId !== chunk.toolCallId) { + return null; + } + + const activityPayload = asObjectRecord(activity.payload); + const previousActivityPayload = asObjectRecord(previousActivity.payload); + const data = asObjectRecord(activityPayload?.data); + const rawOutput = asObjectRecord(data?.rawOutput); + if (!activityPayload || !data || !rawOutput) { + return null; + } + + return { + ...event, + payload: { + ...event.payload, + activity: { + ...activity, + sequence: previousActivity.sequence ?? activity.sequence, + createdAt: previousActivity.createdAt, + payload: { + ...activityPayload, + detail: activityPayload.detail ?? previousActivityPayload?.detail, + data: { + ...data, + rawOutput: { + ...rawOutput, + content: previousChunk.content + chunk.content, + }, + }, + }, + }, + }, + }; +} + +export function coalesceOrchestrationUiEvents( events: ReadonlyArray<OrchestrationEvent>, ): OrchestrationEvent[] { if (events.length < 2) { @@ -941,6 +1103,17 @@ function coalesceOrchestrationUiEvents( continue; } + if ( + previous?.type === "thread.activity-appended" && + event.type === "thread.activity-appended" + ) { + const coalescedSubagentEvent = coalesceSubagentOutputEvent(previous, event); + if (coalescedSubagentEvent) { + coalesced[coalesced.length - 1] = coalescedSubagentEvent; + continue; + } + } + coalesced.push(event); } diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 7325a96913d..e9667ecfc1b 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -19,10 +19,33 @@ import { } from "../store"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import type { Thread } from "../types"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; +function collectLifecycleThreadIds( + threads: readonly Pick<Thread, "id" | "parentRelation">[], + rootThreadIds: ReadonlySet<ThreadId>, +): Set<ThreadId> { + const threadIds = new Set(rootThreadIds); + for (const thread of threads) { + if ( + thread.parentRelation?.kind === "subagent" && + rootThreadIds.has(thread.parentRelation.rootThreadId) + ) { + threadIds.add(thread.id); + } + } + return threadIds; +} + +function withRootLast(threadIds: ReadonlySet<ThreadId>, rootThreadId: ThreadId): ThreadId[] { + return [...threadIds].sort((left, right) => + left === rootThreadId ? 1 : right === rootThreadId ? -1 : 0, + ); +} + export function useThreadActions() { const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); @@ -63,25 +86,37 @@ export function useThreadActions() { const resolved = resolveThreadTarget(target); if (!resolved) return; const { thread, threadRef } = resolved; - if (thread.session?.status === "running" && thread.session.activeTurnId != null) { + const threads = selectThreadsForEnvironment(useStore.getState(), threadRef.environmentId); + const archivedThreadIds = collectLifecycleThreadIds(threads, new Set([threadRef.threadId])); + if ( + threads.some( + (entry) => + archivedThreadIds.has(entry.id) && + entry.session?.status === "running" && + entry.session.activeTurnId != null, + ) + ) { throw new Error("Cannot archive a running thread."); } const currentRouteThreadRef = getCurrentRouteThreadRef(); const shouldNavigateToDraft = - currentRouteThreadRef?.threadId === threadRef.threadId && - currentRouteThreadRef.environmentId === threadRef.environmentId; - const archiveCommand = api.orchestration.dispatchCommand({ - type: "thread.archive", - commandId: newCommandId(), - threadId: threadRef.threadId, - }); + currentRouteThreadRef?.environmentId === threadRef.environmentId && + archivedThreadIds.has(currentRouteThreadRef.threadId); if (shouldNavigateToDraft) { await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)); } - await archiveCommand; + await Promise.all( + withRootLast(archivedThreadIds, threadRef.threadId).map((threadId) => + api.orchestration.dispatchCommand({ + type: "thread.archive", + commandId: newCommandId(), + threadId, + }), + ), + ); refreshArchivedThreadsForEnvironment(threadRef.environmentId); }, [getCurrentRouteThreadRef, resolveThreadTarget], @@ -120,7 +155,7 @@ export function useThreadActions() { environmentId: threadRef.environmentId, projectId: thread.projectId, }); - const deletedIds = + const selectedDeleteRootIds = opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0 ? new Set<ThreadId>( [...opts.deletedThreadKeys].flatMap((threadKey) => { @@ -129,6 +164,11 @@ export function useThreadActions() { }), ) : undefined; + const targetThreadIds = collectLifecycleThreadIds(threads, new Set([threadRef.threadId])); + const deletedIds = + selectedDeleteRootIds && selectedDeleteRootIds.size > 0 + ? collectLifecycleThreadIds(threads, selectedDeleteRootIds) + : targetThreadIds; const survivingThreads = deletedIds && deletedIds.size > 0 ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) @@ -154,46 +194,60 @@ export function useThreadActions() { ].join("\n"), )); - if (thread.session && thread.session.status !== "closed") { - await api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId: threadRef.threadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); - } + for (const deletedThreadId of targetThreadIds) { + const deletedThread = threads.find((entry) => entry.id === deletedThreadId); + if (deletedThread?.session && deletedThread.session.status !== "closed") { + await api.orchestration + .dispatchCommand({ + type: "thread.session.stop", + commandId: newCommandId(), + threadId: deletedThreadId, + createdAt: new Date().toISOString(), + }) + .catch(() => undefined); + } - try { - await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true }); - } catch { - // Terminal may already be closed. + try { + await api.terminal.close({ threadId: deletedThreadId, deleteHistory: true }); + } catch { + // Terminal may already be closed. + } } - const deletedThreadIds = deletedIds ?? new Set<ThreadId>(); const currentRouteThreadRef = getCurrentRouteThreadRef(); - const shouldNavigateToFallback = - currentRouteThreadRef?.threadId === threadRef.threadId && - currentRouteThreadRef.environmentId === threadRef.environmentId; + const activeDeletedThreadId = + currentRouteThreadRef?.environmentId === threadRef.environmentId && + deletedIds.has(currentRouteThreadRef.threadId) + ? currentRouteThreadRef.threadId + : null; + const shouldNavigateToFallback = activeDeletedThreadId !== null; + const deletedThreadIdForFallback = activeDeletedThreadId ?? threadRef.threadId; const fallbackThreadId = getFallbackThreadIdAfterDelete({ threads, - deletedThreadId: threadRef.threadId, - deletedThreadIds, + deletedThreadId: deletedThreadIdForFallback, + deletedThreadIds: deletedIds, sortOrder: sidebarThreadSortOrder, }); - await api.orchestration.dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: threadRef.threadId, - }); + for (const deletedThreadId of withRootLast(targetThreadIds, threadRef.threadId)) { + await api.orchestration.dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: deletedThreadId, + }); + } refreshArchivedThreadsForEnvironment(threadRef.environmentId); - clearComposerDraftForThread(threadRef); - clearProjectDraftThreadById( - scopeProjectRef(threadRef.environmentId, thread.projectId), - threadRef, - ); - clearTerminalUiState(threadRef); + for (const deletedThreadId of targetThreadIds) { + const deletedThreadRef = scopeThreadRef(threadRef.environmentId, deletedThreadId); + const deletedThread = threads.find((entry) => entry.id === deletedThreadId); + clearComposerDraftForThread(deletedThreadRef); + if (deletedThread) { + clearProjectDraftThreadById( + scopeProjectRef(threadRef.environmentId, deletedThread.projectId), + deletedThreadRef, + ); + } + clearTerminalUiState(deletedThreadRef); + } if (shouldNavigateToFallback) { if (fallbackThreadId) { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index beb40aadff9..2597805e1ca 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1214,6 +1214,340 @@ describe("deriveWorkLogEntries", () => { }); }); + it("collapses streamed subagent output under the parent collab tool id", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "subagent-output-a", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + status: "inProgress", + title: "Subagent", + detail: "Inspect the auth flow", + data: { + toolCallId: "collab-1", + parentCollab: { + itemId: "collab-1", + detail: "Inspect the auth flow", + }, + rawOutput: { + content: "Found the login path. ", + }, + }, + }, + }), + makeActivity({ + id: "subagent-output-b", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + status: "inProgress", + title: "Subagent", + detail: "Inspect the auth flow", + data: { + toolCallId: "collab-1", + parentCollab: { + itemId: "collab-1", + detail: "Inspect the auth flow", + }, + rawOutput: { + content: "No failures.", + }, + }, + }, + }), + makeActivity({ + id: "subagent-complete", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.completed", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + detail: "Inspect the auth flow", + data: { + item: { + id: "collab-1", + }, + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "subagent-complete", + itemType: "collab_agent_tool_call", + subagentPrompt: "Inspect the auth flow", + output: "Found the login path. No failures.", + }); + }); + + it("preserves same-timestamp subagent output chunk order", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "subagent-output-z", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + detail: "Commit staged changes", + data: { + toolCallId: "collab-commit", + parentCollab: { + itemId: "collab-commit", + detail: "Commit staged changes", + }, + rawOutput: { + content: "createdCommit: yes\n\n**hash:** `884f", + }, + }, + }, + }), + makeActivity({ + id: "subagent-output-a", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + detail: "Commit staged changes", + data: { + toolCallId: "collab-commit", + parentCollab: { + itemId: "collab-commit", + detail: "Commit staged changes", + }, + rawOutput: { + content: "619b`\n**subject:** `Fix overage reset display`", + }, + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries).toHaveLength(1); + expect(entries[0]?.output).toBe( + "createdCommit: yes\n\n**hash:** `884f619b`\n**subject:** `Fix overage reset display`", + ); + }); + + it("collapses late subagent output into an already completed subagent row", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "subagent-complete", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.completed", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + detail: "Create a haiku", + data: { + item: { + id: "collab-haiku", + }, + }, + }, + }), + makeActivity({ + id: "subagent-output-late", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + status: "inProgress", + title: "Subagent", + detail: "below", + data: { + toolCallId: "collab-haiku", + parentCollab: { + itemId: "collab-haiku", + detail: "below", + }, + rawOutput: { + content: + "Rain lifts from the wires\nA window gathers pale dawn\nFootsteps bloom below", + }, + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "subagent-output-late", + itemType: "collab_agent_tool_call", + subagentPrompt: "Create a haiku", + output: "Rain lifts from the wires\nA window gathers pale dawn\nFootsteps bloom below", + }); + }); + + it("omits empty subagent placeholders around prompt and output rows", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "subagent-empty-before", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + status: "inProgress", + }, + }), + makeActivity({ + id: "subagent-prompt", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + detail: "Create a haiku", + data: { + item: { + id: "collab-haiku", + prompt: "Create a haiku", + }, + }, + }, + }), + makeActivity({ + id: "subagent-empty-after", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.completed", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + }, + }), + makeActivity({ + id: "subagent-output", + createdAt: "2026-02-23T00:00:04.000Z", + kind: "tool.completed", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + data: { + toolCallId: "collab-haiku", + rawOutput: { + content: "Rain lifts from wires", + }, + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "subagent-output", + itemType: "collab_agent_tool_call", + subagentPrompt: "Create a haiku", + output: "Rain lifts from wires", + }); + }); + + it("drops duplicate subagent control rows for an already rendered child block", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "subagent-spawn", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.completed", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + detail: "Run a harmless shell command", + data: { + item: { + id: "call-spawn", + prompt: "Run a harmless shell command", + tool: "spawnAgent", + }, + subagentChildren: [ + { + childThreadId: "subagent-child-1", + titleSeed: "Run a harmless shell command", + }, + ], + }, + }, + }), + makeActivity({ + id: "subagent-wait", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + data: { + item: { + id: "call-wait", + prompt: null, + tool: "wait", + }, + subagentChildren: [ + { + childThreadId: "subagent-child-1", + }, + ], + }, + }, + }), + makeActivity({ + id: "subagent-close", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.completed", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + data: { + item: { + id: "call-close", + prompt: null, + tool: "closeAgent", + }, + subagentChildren: [ + { + childThreadId: "subagent-child-1", + }, + ], + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "subagent-spawn", + itemType: "collab_agent_tool_call", + subagentChildren: [ + { + threadId: "subagent-child-1", + titleSeed: "Run a harmless shell command", + }, + ], + }); + }); + it("uses completed read-file output previews and still collapses the same tool call", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5576ebeffc1..5b2bac2eb00 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -9,7 +9,7 @@ import { ProviderDriverKind, type ToolLifecycleItemType, type UserInputQuestion, - type ThreadId, + ThreadId, type TurnId, } from "@t3tools/contracts"; @@ -68,7 +68,10 @@ export interface WorkLogEntry { detail?: string; command?: string; rawCommand?: string; + output?: string; changedFiles?: ReadonlyArray<string>; + subagentPrompt?: string; + subagentChildren?: ReadonlyArray<SubagentWorkLogChild>; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; toolData?: unknown; @@ -80,6 +83,11 @@ export interface WorkLogEntry { sourceActivityKind?: OrchestrationThreadActivity["kind"]; } +export interface SubagentWorkLogChild { + threadId: ThreadId; + titleSeed?: string; +} + interface DerivedWorkLogEntry extends WorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; @@ -638,7 +646,9 @@ export function deriveWorkLogEntries( if (isPlanBoundaryToolActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } - return collapseDerivedWorkLogEntries(entries).map((entry) => { + return dedupeSubagentChildWorkEntries( + collapseDerivedWorkLogEntries(entries.filter((entry) => !isEmptySubagentWorkLogEntry(entry))), + ).map((entry) => { const { activityKind, collapseKey: _collapseKey, ...rest } = entry; return Object.assign(rest, { sourceActivityKind: activityKind }); }); @@ -720,6 +730,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo }; const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); + const subagentOutput = + itemType === "collab_agent_tool_call" ? extractSubagentOutput(payload) : null; + const subagentPrompt = + itemType === "collab_agent_tool_call" ? extractSubagentPrompt(payload, detail) : null; + const subagentChildren = + itemType === "collab_agent_tool_call" ? extractSubagentChildren(payload) : []; if (detail) { entry.detail = detail; } @@ -729,9 +745,18 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (commandPreview.rawCommand) { entry.rawCommand = commandPreview.rawCommand; } + if (subagentOutput) { + entry.output = subagentOutput; + } if (changedFiles.length > 0) { entry.changedFiles = changedFiles; } + if (subagentPrompt) { + entry.subagentPrompt = subagentPrompt; + } + if (subagentChildren.length > 0) { + entry.subagentChildren = subagentChildren; + } if (title) { entry.toolTitle = title; } @@ -779,6 +804,48 @@ function collapseDerivedWorkLogEntries( return collapsed; } +function dedupeSubagentChildWorkEntries( + entries: ReadonlyArray<DerivedWorkLogEntry>, +): DerivedWorkLogEntry[] { + const seenChildThreadIds = new Set<string>(); + const deduped: DerivedWorkLogEntry[] = []; + for (const entry of entries) { + if (entry.itemType !== "collab_agent_tool_call" || !entry.subagentChildren?.length) { + deduped.push(entry); + continue; + } + const unseenChildren = entry.subagentChildren.filter((child) => { + if (seenChildThreadIds.has(child.threadId)) { + return false; + } + seenChildThreadIds.add(child.threadId); + return true; + }); + if (unseenChildren.length === 0) { + continue; + } + deduped.push( + unseenChildren.length === entry.subagentChildren.length + ? entry + : { + ...entry, + subagentChildren: unseenChildren, + }, + ); + } + return deduped; +} + +function isEmptySubagentWorkLogEntry(entry: DerivedWorkLogEntry): boolean { + return ( + entry.itemType === "collab_agent_tool_call" && + !entry.detail && + !entry.subagentPrompt && + !entry.output && + (entry.subagentChildren?.length ?? 0) === 0 + ); +} + function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, @@ -789,7 +856,14 @@ function shouldCollapseToolLifecycleEntries( if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { return false; } - if (previous.activityKind === "tool.completed") { + if ( + previous.activityKind === "tool.completed" && + !( + next.activityKind === "tool.updated" && + previous.toolCallId !== undefined && + previous.toolCallId === next.toolCallId + ) + ) { return false; } if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) { @@ -809,23 +883,43 @@ function mergeDerivedWorkLogEntries( next: DerivedWorkLogEntry, ): DerivedWorkLogEntry { const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); - const detail = next.detail ?? previous.detail; + const itemType = next.itemType ?? previous.itemType; + const detail = + itemType === "collab_agent_tool_call" + ? (previous.detail ?? next.detail) + : (next.detail ?? previous.detail); const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; + const output = + itemType === "collab_agent_tool_call" + ? mergeTextOutputChunk(previous.output, next.output) + : mergeTextOutput(previous.output, next.output); + const subagentPrompt = + itemType === "collab_agent_tool_call" + ? (previous.subagentPrompt ?? next.subagentPrompt) + : (next.subagentPrompt ?? previous.subagentPrompt); + const subagentChildren = + itemType === "collab_agent_tool_call" + ? mergeSubagentChildren(previous.subagentChildren, next.subagentChildren) + : (next.subagentChildren ?? previous.subagentChildren); const toolTitle = next.toolTitle ?? previous.toolTitle; - const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; - const collapseKey = next.collapseKey ?? previous.collapseKey; const toolCallId = next.toolCallId ?? previous.toolCallId; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; const toolData = next.toolData ?? previous.toolData; + const collapseKey = toolCallId + ? `tool:${toolCallId}` + : (next.collapseKey ?? previous.collapseKey); return { ...previous, ...next, ...(detail ? { detail } : {}), ...(command ? { command } : {}), ...(rawCommand ? { rawCommand } : {}), + ...(output ? { output } : {}), ...(changedFiles.length > 0 ? { changedFiles } : {}), + ...(subagentPrompt ? { subagentPrompt } : {}), + ...(subagentChildren && subagentChildren.length > 0 ? { subagentChildren } : {}), ...(toolTitle ? { toolTitle } : {}), ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), @@ -836,6 +930,52 @@ function mergeDerivedWorkLogEntries( }; } +function mergeSubagentChildren( + previous: ReadonlyArray<SubagentWorkLogChild> | undefined, + next: ReadonlyArray<SubagentWorkLogChild> | undefined, +): ReadonlyArray<SubagentWorkLogChild> | undefined { + const merged = [...(previous ?? []), ...(next ?? [])]; + if (merged.length === 0) { + return undefined; + } + const byThreadId = new Map<ThreadId, SubagentWorkLogChild>(); + for (const child of merged) { + const existing = byThreadId.get(child.threadId); + const titleSeed = existing?.titleSeed ?? child.titleSeed; + byThreadId.set(child.threadId, { + threadId: child.threadId, + ...(titleSeed ? { titleSeed } : {}), + }); + } + return [...byThreadId.values()]; +} + +function mergeTextOutput( + previous: string | undefined, + next: string | undefined, +): string | undefined { + if (!previous) { + return next; + } + if (!next) { + return previous; + } + return `${previous}${next}`; +} + +function mergeTextOutputChunk( + previous: string | undefined, + next: string | undefined, +): string | undefined { + if (!previous) { + return next; + } + if (!next) { + return previous; + } + return `${previous}${next}`; +} + function mergeChangedFiles( previous: ReadonlyArray<string> | undefined, next: ReadonlyArray<string> | undefined, @@ -1084,7 +1224,14 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul function extractToolCallId(payload: Record<string, unknown> | null): string | null { const data = asRecord(payload?.data); - return asTrimmedString(data?.toolCallId); + const item = asRecord(data?.item); + const parentCollab = asRecord(data?.parentCollab); + return ( + asTrimmedString(data?.toolCallId) ?? + asTrimmedString(parentCollab?.itemId) ?? + asTrimmedString(data?.itemId) ?? + asTrimmedString(item?.id) + ); } function normalizeInlinePreview(value: string): string { @@ -1211,6 +1358,85 @@ function stripTrailingExitCode(value: string): { }; } +function firstStringFromRecord( + record: Record<string, unknown> | null, + keys: ReadonlyArray<string>, +): string | null { + if (!record) { + return null; + } + for (const key of keys) { + const value = asTrimmedString(record[key]); + if (value) { + return value; + } + } + return null; +} + +function firstRawStringFromRecord( + record: Record<string, unknown> | null, + keys: ReadonlyArray<string>, +): string | null { + if (!record) { + return null; + } + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + } + return null; +} + +function extractSubagentOutput(payload: Record<string, unknown> | null): string | null { + const data = asRecord(payload?.data); + const rawOutput = asRecord(data?.rawOutput); + return firstRawStringFromRecord(rawOutput, ["content", "output", "text", "stdout", "result"]); +} + +function extractSubagentPrompt( + payload: Record<string, unknown> | null, + fallbackDetail: string | null, +): string | null { + const data = asRecord(payload?.data); + const item = asRecord(data?.item); + const itemInput = asRecord(item?.input); + const rawInput = asRecord(data?.rawInput); + const parentCollab = asRecord(data?.parentCollab); + return ( + asTrimmedString(parentCollab?.detail) ?? + firstStringFromRecord(itemInput, ["prompt", "message", "description", "task"]) ?? + firstStringFromRecord(rawInput, ["prompt", "message", "description", "task"]) ?? + firstStringFromRecord(item, ["prompt", "message", "description", "task"]) ?? + fallbackDetail + ); +} + +function extractSubagentChildren( + payload: Record<string, unknown> | null, +): ReadonlyArray<SubagentWorkLogChild> { + const data = asRecord(payload?.data); + const children = Array.isArray(data?.subagentChildren) ? data.subagentChildren : []; + const result: SubagentWorkLogChild[] = []; + const seen = new Set<string>(); + for (const value of children) { + const record = asRecord(value); + const rawThreadId = asTrimmedString(record?.childThreadId) ?? asTrimmedString(record?.threadId); + if (!rawThreadId || seen.has(rawThreadId)) { + continue; + } + seen.add(rawThreadId); + const titleSeed = asTrimmedString(record?.titleSeed); + result.push({ + threadId: ThreadId.make(rawThreadId), + ...(titleSeed ? { titleSeed } : {}), + }); + } + return result; +} + function extractWorkLogItemType( payload: Record<string, unknown> | null, ): WorkLogEntry["itemType"] | undefined { @@ -1322,7 +1548,10 @@ function compareActivitiesByOrder( return lifecycleRankComparison; } - return left.id.localeCompare(right.id); + // Stable sort preserves arrival order for unsequenced same-timestamp events. + // Streaming text chunks can share millisecond timestamps; sorting those by + // random event ids can scramble the reconstructed output. + return 0; } function compareActivityLifecycleRank(kind: string): number { diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 2fe06d518d2..b313ec53b9c 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -10,6 +10,7 @@ import { ThreadId, TurnId, type OrchestrationEvent, + type OrchestrationThreadActivity, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -19,6 +20,8 @@ import { removeEnvironmentState, selectEnvironmentState, selectProjectsAcrossEnvironments, + selectSidebarThreadsAcrossEnvironments, + selectSidebarThreadsAcrossEnvironmentsForRoute, selectThreadByRef, selectThreadExistsByRef, setThreadBranch, @@ -26,7 +29,12 @@ import { type AppState, type EnvironmentState, } from "./store"; -import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; +import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type SidebarThreadSummary, + type Thread, +} from "./types"; const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); @@ -87,6 +95,29 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { }; } +function makeSidebarThreadSummary( + overrides: Partial<SidebarThreadSummary> = {}, +): SidebarThreadSummary { + return { + id: ThreadId.make("thread-1"), + environmentId: localEnvironmentId, + projectId: ProjectId.make("project-1"), + title: "Thread", + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + createdAt: "2026-02-13T00:00:00.000Z", + archivedAt: null, + latestTurn: null, + branch: null, + worktreePath: null, + latestUserMessageAt: "2026-02-13T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + function makeState(thread: Thread): AppState { const projectId = ProjectId.make("project-1"); const project = { @@ -220,6 +251,120 @@ function threadsOf(state: AppState) { return selectThreadsAcrossEnvironments(state); } +function makeSidebarState(summaries: readonly SidebarThreadSummary[]): AppState { + const projectId = ProjectId.make("project-1"); + return makeEmptyState({ + projectIds: [projectId], + projectById: { + [projectId]: { + id: projectId, + environmentId: localEnvironmentId, + name: "Project", + cwd: "/tmp/project", + defaultModelSelection: null, + scripts: [], + }, + }, + threadIds: summaries.map((thread) => thread.id), + threadIdsByProjectId: { + [projectId]: summaries.map((thread) => thread.id), + }, + sidebarThreadSummaryById: Object.fromEntries( + summaries.map((thread) => [thread.id, thread] as const), + ), + }); +} + +function makeSubagentParentRelation(input: { + rootThreadId: ThreadId; + parentThreadId: ThreadId; + status: "running" | "completed" | "errored" | "interrupted" | "stopped"; + depth?: number; +}) { + return { + kind: "subagent", + rootThreadId: input.rootThreadId, + parentThreadId: input.parentThreadId, + parentTurnId: null, + parentItemId: "item-subagent" as never, + parentActivitySequence: 1, + providerThreadId: `provider-${input.parentThreadId}`, + titleSeed: null, + depth: input.depth ?? 1, + startedAt: "2026-02-13T00:01:00.000Z", + completedAt: input.status === "running" ? null : "2026-02-13T00:02:00.000Z", + status: input.status, + } satisfies NonNullable<SidebarThreadSummary["parentRelation"]>; +} + +describe("selectSidebarThreadsAcrossEnvironmentsForRoute", () => { + it("hides completed subagent children outside the active route path", () => { + const parent = makeSidebarThreadSummary({ id: ThreadId.make("parent") }); + const child = makeSidebarThreadSummary({ + id: ThreadId.make("child"), + parentRelation: makeSubagentParentRelation({ + rootThreadId: parent.id, + parentThreadId: parent.id, + status: "completed", + }), + }); + const state = makeSidebarState([parent, child]); + + expect(selectSidebarThreadsAcrossEnvironments(state).map((thread) => thread.id)).toEqual([ + parent.id, + ]); + }); + + it("includes a completed child while that child route is active", () => { + const parent = makeSidebarThreadSummary({ id: ThreadId.make("parent") }); + const child = makeSidebarThreadSummary({ + id: ThreadId.make("child"), + parentRelation: makeSubagentParentRelation({ + rootThreadId: parent.id, + parentThreadId: parent.id, + status: "completed", + }), + }); + const state = makeSidebarState([parent, child]); + + expect( + selectSidebarThreadsAcrossEnvironmentsForRoute( + state, + scopeThreadRef(localEnvironmentId, child.id), + ).map((thread) => thread.id), + ).toEqual([parent.id, child.id]); + }); + + it("includes intermediate completed children for nested active subagents", () => { + const parent = makeSidebarThreadSummary({ id: ThreadId.make("parent") }); + const child = makeSidebarThreadSummary({ + id: ThreadId.make("child"), + parentRelation: makeSubagentParentRelation({ + rootThreadId: parent.id, + parentThreadId: parent.id, + status: "completed", + }), + }); + const grandchild = makeSidebarThreadSummary({ + id: ThreadId.make("grandchild"), + parentRelation: makeSubagentParentRelation({ + rootThreadId: parent.id, + parentThreadId: child.id, + status: "completed", + depth: 2, + }), + }); + const state = makeSidebarState([parent, child, grandchild]); + + expect( + selectSidebarThreadsAcrossEnvironmentsForRoute( + state, + scopeThreadRef(localEnvironmentId, grandchild.id), + ).map((thread) => thread.id), + ).toEqual([parent.id, child.id, grandchild.id]); + }); +}); + function makeEvent<T extends OrchestrationEvent["type"]>( type: T, payload: Extract<OrchestrationEvent, { type: T }>["payload"], @@ -247,6 +392,47 @@ function makeEvent<T extends OrchestrationEvent["type"]>( } as Extract<OrchestrationEvent, { type: T }>; } +function makeActivity( + overrides: Partial<OrchestrationThreadActivity> = {}, +): OrchestrationThreadActivity { + return { + id: EventId.make("activity-1"), + tone: "info", + kind: "step", + summary: "Activity", + payload: {}, + turnId: TurnId.make("turn-1"), + createdAt: "2026-02-27T00:00:00.000Z", + ...overrides, + }; +} + +function makeSubagentOutputActivity(input: { + readonly id: string; + readonly sequence: number; + readonly content: string; +}): OrchestrationThreadActivity { + return makeActivity({ + id: EventId.make(input.id), + tone: "tool", + kind: "tool.updated", + summary: "Subagent", + turnId: TurnId.make("turn-1"), + sequence: input.sequence, + createdAt: `2026-02-27T00:00:00.${String(input.sequence).padStart(3, "0")}Z`, + payload: { + itemType: "collab_agent_tool_call", + detail: "Create a haiku", + data: { + toolCallId: "collab-1", + rawOutput: { + content: input.content, + }, + }, + }, + }); +} + describe("environment state removal", () => { it("drops local state for removed environments", () => { const removedThread = makeThread({ @@ -486,6 +672,62 @@ describe("incremental orchestration updates", () => { expect(nextAfterThreadDelete).toBe(state); }); + it("merges live subagent output chunks before applying the activity retention cap", () => { + const firstSubagentChunk = makeSubagentOutputActivity({ + id: "subagent-output-1", + sequence: 1, + content: "Rain lifts ", + }); + const fillerActivities = Array.from({ length: 499 }, (_, index) => { + const sequence = index + 2; + return makeActivity({ + id: EventId.make(`activity-${sequence}`), + sequence, + createdAt: `2026-02-27T00:00:00.${String(sequence).padStart(3, "0")}Z`, + }); + }); + const state = makeState( + makeThread({ + activities: [firstSubagentChunk, ...fillerActivities], + }), + ); + + const next = applyOrchestrationEvent( + state, + makeEvent( + "thread.activity-appended", + { + threadId: ThreadId.make("thread-1"), + activity: makeSubagentOutputActivity({ + id: "subagent-output-2", + sequence: 501, + content: "from wires", + }), + }, + { sequence: 501, occurredAt: "2026-02-27T00:08:21.000Z" }, + ), + localEnvironmentId, + ); + + const activities = threadsOf(next)[0]?.activities ?? []; + const mergedActivity = activities.find((activity) => activity.id === firstSubagentChunk.id); + const mergedPayload = mergedActivity?.payload as + | { + data?: { + rawOutput?: { + content?: string; + }; + }; + } + | undefined; + + expect(activities).toHaveLength(500); + expect(activities.some((activity) => activity.id === EventId.make("subagent-output-2"))).toBe( + false, + ); + expect(mergedPayload?.data?.rawOutput?.content).toBe("Rain lifts from wires"); + }); + it("reuses an existing project row when project.created arrives with a new id for the same cwd", () => { const originalProjectId = ProjectId.make("project-1"); const recreatedProjectId = ProjectId.make("project-2"); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 9a9b05f92f2..e1469fbd66b 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -252,6 +252,7 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, branch: thread.branch, worktreePath: thread.worktreePath, + parentRelation: thread.parentRelation, turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary), activities: thread.activities.map((activity) => ({ ...activity })), }; @@ -281,6 +282,7 @@ function mapThreadShell( updatedAt: thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, + parentRelation: thread.parentRelation, }; const session = thread.session ? mapSession(thread.session) : null; const turnState: ThreadTurnState = { @@ -300,6 +302,7 @@ function mapThreadShell( latestTurn: thread.latestTurn, branch: thread.branch, worktreePath: thread.worktreePath, + parentRelation: thread.parentRelation, latestUserMessageAt: thread.latestUserMessageAt, hasPendingApprovals: thread.hasPendingApprovals, hasPendingUserInput: thread.hasPendingUserInput, @@ -329,6 +332,7 @@ function toThreadShell(thread: Thread): ThreadShell { updatedAt: thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, + parentRelation: thread.parentRelation, }; } @@ -367,6 +371,28 @@ function latestTurnsEqual( ); } +function threadParentRelationsEqual( + left: ThreadShell["parentRelation"] | undefined, + right: ThreadShell["parentRelation"] | undefined, +): boolean { + if (left === right) return true; + if (left === undefined || right === undefined) return false; + if (left.kind !== right.kind || left.rootThreadId !== right.rootThreadId) return false; + if (left.kind === "root" || right.kind === "root") return left.kind === right.kind; + return ( + left.parentThreadId === right.parentThreadId && + left.parentTurnId === right.parentTurnId && + left.parentItemId === right.parentItemId && + left.parentActivitySequence === right.parentActivitySequence && + left.providerThreadId === right.providerThreadId && + left.titleSeed === right.titleSeed && + left.depth === right.depth && + left.startedAt === right.startedAt && + left.completedAt === right.completedAt && + left.status === right.status + ); +} + function threadSessionsEqual( left: ThreadSession | null | undefined, right: ThreadSession | null | undefined, @@ -401,6 +427,7 @@ function sidebarThreadSummariesEqual( latestTurnsEqual(left.latestTurn, right.latestTurn) && left.branch === right.branch && left.worktreePath === right.worktreePath && + threadParentRelationsEqual(left.parentRelation, right.parentRelation) && left.latestUserMessageAt === right.latestUserMessageAt && left.hasPendingApprovals === right.hasPendingApprovals && left.hasPendingUserInput === right.hasPendingUserInput && @@ -424,7 +451,8 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b left.archivedAt === right.archivedAt && left.updatedAt === right.updatedAt && left.branch === right.branch && - left.worktreePath === right.worktreePath + left.worktreePath === right.worktreePath && + threadParentRelationsEqual(left.parentRelation, right.parentRelation) ); } @@ -855,6 +883,147 @@ function compareActivities( return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); } +function asRecord(value: unknown): Record<string, unknown> | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + + return value as Record<string, unknown>; +} + +function asNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function extractSubagentToolCallId(activity: OrchestrationThreadActivity): string | null { + if (activity.kind !== "tool.updated") { + return null; + } + + const payload = asRecord(activity.payload); + if (payload?.itemType !== "collab_agent_tool_call") { + return null; + } + + const data = asRecord(payload.data); + const parentCollab = asRecord(data?.parentCollab); + return asNonEmptyString(data?.toolCallId) ?? asNonEmptyString(parentCollab?.itemId); +} + +function extractSubagentRawOutputContent(activity: OrchestrationThreadActivity): string | null { + const payload = asRecord(activity.payload); + const data = asRecord(payload?.data); + const rawOutput = asRecord(data?.rawOutput); + return typeof rawOutput?.content === "string" ? rawOutput.content : null; +} + +function sameNullableTurnId( + left: OrchestrationThreadActivity["turnId"], + right: OrchestrationThreadActivity["turnId"], +): boolean { + return left === right; +} + +function mergeSubagentOutputActivity(input: { + baseActivity: OrchestrationThreadActivity; + nextActivity: OrchestrationThreadActivity; + content: string; +}): OrchestrationThreadActivity { + const basePayload = asRecord(input.baseActivity.payload) ?? {}; + const nextPayload = asRecord(input.nextActivity.payload) ?? {}; + const baseData = asRecord(basePayload.data) ?? {}; + const nextData = asRecord(nextPayload.data) ?? {}; + const baseRawOutput = asRecord(baseData.rawOutput) ?? {}; + const nextRawOutput = asRecord(nextData.rawOutput) ?? {}; + + return { + ...input.baseActivity, + tone: input.nextActivity.tone, + kind: input.nextActivity.kind, + summary: input.nextActivity.summary, + payload: { + ...basePayload, + ...nextPayload, + detail: basePayload.detail ?? nextPayload.detail, + data: { + ...baseData, + ...nextData, + rawOutput: { + ...baseRawOutput, + ...nextRawOutput, + content: input.content, + }, + }, + }, + }; +} + +function mergeLiveSubagentOutputActivity( + activities: ReadonlyArray<OrchestrationThreadActivity>, + nextActivity: OrchestrationThreadActivity, +): OrchestrationThreadActivity[] | null { + const nextToolCallId = extractSubagentToolCallId(nextActivity); + const nextContent = extractSubagentRawOutputContent(nextActivity); + if (!nextToolCallId || nextContent === null || nextContent.length === 0) { + return null; + } + + let baseActivity: OrchestrationThreadActivity | null = null; + let insertIndex = -1; + let content = ""; + const retained: OrchestrationThreadActivity[] = []; + + for (const activity of activities) { + if (activity.id === nextActivity.id) { + return null; + } + + const activityToolCallId = extractSubagentToolCallId(activity); + if ( + activityToolCallId === nextToolCallId && + sameNullableTurnId(activity.turnId, nextActivity.turnId) + ) { + baseActivity ??= activity; + if (insertIndex === -1) { + insertIndex = retained.length; + } + content += extractSubagentRawOutputContent(activity) ?? ""; + continue; + } + + retained.push(activity); + } + + if (!baseActivity) { + return null; + } + + const mergedActivity = mergeSubagentOutputActivity({ + baseActivity, + nextActivity, + content: `${content}${nextContent}`, + }); + + return [...retained.slice(0, insertIndex), mergedActivity, ...retained.slice(insertIndex)]; +} + +function appendLiveThreadActivity( + activities: ReadonlyArray<OrchestrationThreadActivity>, + nextActivity: OrchestrationThreadActivity, +): OrchestrationThreadActivity[] { + const mergedActivities = mergeLiveSubagentOutputActivity(activities, nextActivity); + if (mergedActivities) { + return mergedActivities; + } + + return [...activities.filter((activity) => activity.id !== nextActivity.id), { ...nextActivity }]; +} + function buildLatestTurn(params: { previous: Thread["latestTurn"]; turnId: NonNullable<Thread["latestTurn"]>["turnId"]; @@ -1329,6 +1498,9 @@ function applyEnvironmentOrchestrationEvent( ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.parentRelation !== undefined + ? { parentRelation: event.payload.parentRelation } + : {}), updatedAt: event.payload.updatedAt, })); @@ -1677,10 +1849,7 @@ function applyEnvironmentOrchestrationEvent( case "thread.activity-appended": return updateThreadState(state, event.payload.threadId, (thread) => { - const activities = [ - ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), - { ...event.payload.activity }, - ] + const activities = appendLiveThreadActivity(thread.activities, event.payload.activity) .toSorted(compareActivities) .slice(-MAX_THREAD_ACTIVITIES); return { @@ -1826,11 +1995,71 @@ export function selectThreadShellsAcrossEnvironments(state: AppState): ThreadShe ); } +function sidebarThreadSummaryKey(thread: Pick<SidebarThreadSummary, "environmentId" | "id">) { + return `${thread.environmentId}:${thread.id}`; +} + +function collectActiveSubagentSidebarPathKeys( + state: AppState, + activeThreadRef: ScopedThreadRef | null | undefined, +): ReadonlySet<string> { + if (!activeThreadRef) { + return new Set(); + } + + const activeEnvironmentState = selectEnvironmentState(state, activeThreadRef.environmentId); + let current = activeEnvironmentState.sidebarThreadSummaryById[activeThreadRef.threadId] ?? null; + const pathKeys = new Set<string>(); + const visitedKeys = new Set<string>(); + + while (current?.parentRelation?.kind === "subagent") { + const currentKey = sidebarThreadSummaryKey(current); + if (visitedKeys.has(currentKey)) { + break; + } + visitedKeys.add(currentKey); + pathKeys.add(currentKey); + + const parent = selectEnvironmentState(state, current.environmentId).sidebarThreadSummaryById[ + current.parentRelation.parentThreadId + ]; + if (!parent) { + break; + } + current = parent; + } + + return pathKeys; +} + +function shouldShowThreadInSidebar( + thread: SidebarThreadSummary, + activeSubagentPathKeys: ReadonlySet<string> = new Set(), +): boolean { + return ( + thread.parentRelation?.kind !== "subagent" || + thread.parentRelation.status === "running" || + activeSubagentPathKeys.has(sidebarThreadSummaryKey(thread)) + ); +} + export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] { + return selectSidebarThreadsAcrossEnvironmentsForRoute(state, null); +} + +export function selectSidebarThreadsAcrossEnvironmentsForRoute( + state: AppState, + activeThreadRef: ScopedThreadRef | null | undefined, +): SidebarThreadSummary[] { + const activeSubagentPathKeys = collectActiveSubagentSidebarPathKeys(state, activeThreadRef); return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) => environmentState.threadIds.flatMap((threadId) => { const thread = environmentState.sidebarThreadSummaryById[threadId]; - return thread && thread.environmentId === environmentId ? [thread] : []; + return thread && + thread.environmentId === environmentId && + shouldShowThreadInSidebar(thread, activeSubagentPathKeys) + ? [thread] + : []; }), ); } @@ -1838,26 +2067,47 @@ export function selectSidebarThreadsAcrossEnvironments(state: AppState): Sidebar export function selectSidebarThreadsForProjectRef( state: AppState, ref: ScopedProjectRef | null | undefined, +): SidebarThreadSummary[] { + return selectSidebarThreadsForProjectRefForRoute(state, ref, null); +} + +export function selectSidebarThreadsForProjectRefForRoute( + state: AppState, + ref: ScopedProjectRef | null | undefined, + activeThreadRef: ScopedThreadRef | null | undefined, ): SidebarThreadSummary[] { if (!ref) { return []; } + const activeSubagentPathKeys = collectActiveSubagentSidebarPathKeys(state, activeThreadRef); const environmentState = selectEnvironmentState(state, ref.environmentId); const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? EMPTY_THREAD_IDS; return threadIds.flatMap((threadId) => { const thread = environmentState.sidebarThreadSummaryById[threadId]; - return thread ? [thread] : []; + return thread && shouldShowThreadInSidebar(thread, activeSubagentPathKeys) ? [thread] : []; }); } export function selectSidebarThreadsForProjectRefs( state: AppState, refs: readonly ScopedProjectRef[], +): SidebarThreadSummary[] { + return selectSidebarThreadsForProjectRefsForRoute(state, refs, null); +} + +export function selectSidebarThreadsForProjectRefsForRoute( + state: AppState, + refs: readonly ScopedProjectRef[], + activeThreadRef: ScopedThreadRef | null | undefined, ): SidebarThreadSummary[] { if (refs.length === 0) return []; - if (refs.length === 1) return selectSidebarThreadsForProjectRef(state, refs[0]); - return refs.flatMap((ref) => selectSidebarThreadsForProjectRef(state, ref)); + if (refs.length === 1) { + return selectSidebarThreadsForProjectRefForRoute(state, refs[0], activeThreadRef); + } + return refs.flatMap((ref) => + selectSidebarThreadsForProjectRefForRoute(state, ref, activeThreadRef), + ); } export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean { diff --git a/apps/web/src/subagentDisplay.test.tsx b/apps/web/src/subagentDisplay.test.tsx new file mode 100644 index 00000000000..46981aa55de --- /dev/null +++ b/apps/web/src/subagentDisplay.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + formatRunningSubagentDuration, + formatTerminalSubagentStatusDuration, + subagentDurationFallbackLabel, +} from "./subagentDisplay"; + +describe("subagentDurationFallbackLabel", () => { + it("does not report terminal subagents without completion time as completed", () => { + expect(subagentDurationFallbackLabel("completed")).toBe("duration unknown"); + expect(subagentDurationFallbackLabel("errored")).toBe("duration unknown"); + expect(subagentDurationFallbackLabel("interrupted")).toBe("duration unknown"); + expect(subagentDurationFallbackLabel("stopped")).toBe("duration unknown"); + }); + + it("keeps distinct fallback labels for running and unknown relations", () => { + expect(subagentDurationFallbackLabel("running")).toBe("Working"); + expect(subagentDurationFallbackLabel(null)).toBe("status unknown"); + }); +}); + +describe("formatRunningSubagentDuration", () => { + it("uses working wording for active subagents", () => { + expect(formatRunningSubagentDuration(new Date().toISOString())).toMatch(/^Working( for .+)?$/); + }); +}); + +describe("formatTerminalSubagentStatusDuration", () => { + it("formats successful completion as completed in duration", () => { + expect(formatTerminalSubagentStatusDuration("completed", "5s")).toBe("Completed in 5s"); + }); +}); diff --git a/apps/web/src/subagentDisplay.tsx b/apps/web/src/subagentDisplay.tsx new file mode 100644 index 00000000000..718c1e987d9 --- /dev/null +++ b/apps/web/src/subagentDisplay.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef } from "react"; + +import { formatElapsed } from "./session-logic"; +import type { ThreadShell } from "./types"; + +export type SubagentThreadStatus = Extract< + NonNullable<ThreadShell["parentRelation"]>, + { kind: "subagent" } +>["status"]; + +export function subagentStatusLabel(status: SubagentThreadStatus | null): string { + switch (status) { + case "running": + return "Running"; + case "completed": + return "Completed"; + case "errored": + return "Errored"; + case "interrupted": + return "Interrupted"; + case "stopped": + return "Stopped"; + case null: + return "Unknown"; + } +} + +export function subagentStatusToneClass(status: SubagentThreadStatus | null): string { + switch (status) { + case "running": + return "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"; + case "completed": + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; + case "errored": + return "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300"; + case "interrupted": + case "stopped": + return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; + case null: + return "border-muted-foreground/25 bg-muted/40 text-muted-foreground"; + } +} + +export function formatSubagentDuration(startIso: string, endIso: string | null): string | null { + if (!endIso) { + return null; + } + return formatElapsed(startIso, endIso); +} + +export function subagentDurationFallbackLabel(status: SubagentThreadStatus | null): string { + switch (status) { + case "running": + return "Working"; + case "completed": + case "errored": + case "interrupted": + case "stopped": + return "duration unknown"; + case null: + return "status unknown"; + } +} + +export function formatRunningSubagentDuration(startedAt: string): string { + const elapsed = formatElapsed(startedAt, new Date().toISOString()); + return elapsed ? `Working for ${elapsed}` : "Working"; +} + +export function formatTerminalSubagentStatusDuration( + status: Exclude<SubagentThreadStatus, "running"> | null, + duration: string | null, +): string { + if (!duration) { + return subagentDurationFallbackLabel(status); + } + + switch (status) { + case "completed": + return `Completed in ${duration}`; + case "errored": + return `Errored after ${duration}`; + case "interrupted": + return `Interrupted after ${duration}`; + case "stopped": + return `Stopped after ${duration}`; + case null: + return subagentDurationFallbackLabel(null); + } +} + +export function LiveSubagentDuration({ startedAt }: { startedAt: string }) { + const ref = useRef<HTMLSpanElement>(null); + const initialText = formatRunningSubagentDuration(startedAt); + + useEffect(() => { + const update = () => { + if (ref.current) { + ref.current.textContent = formatRunningSubagentDuration(startedAt); + } + }; + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [startedAt]); + + return <span ref={ref}>{initialText}</span>; +} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index d508e3c6010..c279d79d1ef 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -6,6 +6,7 @@ import type { RepositoryIdentity, OrchestrationSessionStatus, OrchestrationThreadActivity, + OrchestrationThreadParentRelation, ProjectScript as ContractProjectScript, ThreadId, ProjectId, @@ -114,6 +115,7 @@ export interface Thread { pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; branch: string | null; worktreePath: string | null; + parentRelation?: OrchestrationThreadParentRelation | undefined; turnDiffSummaries: TurnDiffSummary[]; activities: OrchestrationThreadActivity[]; } @@ -133,6 +135,7 @@ export interface ThreadShell { updatedAt?: string | undefined; branch: string | null; worktreePath: string | null; + parentRelation?: OrchestrationThreadParentRelation | undefined; } export interface ThreadTurnState { @@ -153,6 +156,7 @@ export interface SidebarThreadSummary { latestTurn: OrchestrationLatestTurn | null; branch: string | null; worktreePath: string | null; + parentRelation?: OrchestrationThreadParentRelation | undefined; latestUserMessageAt: string | null; hasPendingApprovals: boolean; hasPendingUserInput: boolean; diff --git a/packages/client-runtime/src/threadDetailReducer.ts b/packages/client-runtime/src/threadDetailReducer.ts index 53bad5785b9..35be0a51cf4 100644 --- a/packages/client-runtime/src/threadDetailReducer.ts +++ b/packages/client-runtime/src/threadDetailReducer.ts @@ -133,6 +133,9 @@ export function applyThreadDetailEvent( ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.parentRelation !== undefined + ? { parentRelation: event.payload.parentRelation } + : {}), updatedAt: event.payload.updatedAt, }, }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 46d51da371f..e62a37bd382 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -341,6 +341,28 @@ export const OrchestrationLatestTurn = Schema.Struct({ }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; +export const OrchestrationThreadParentRelation = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("root"), + rootThreadId: ThreadId, + }), + Schema.Struct({ + kind: Schema.Literal("subagent"), + rootThreadId: ThreadId, + parentThreadId: ThreadId, + parentTurnId: Schema.NullOr(TurnId), + parentItemId: ProviderItemId, + parentActivitySequence: NonNegativeInt, + providerThreadId: TrimmedNonEmptyString, + titleSeed: Schema.NullOr(TrimmedNonEmptyString), + depth: NonNegativeInt, + startedAt: IsoDateTime, + completedAt: Schema.NullOr(IsoDateTime), + status: Schema.Literals(["running", "completed", "errored", "interrupted", "stopped"]), + }), +]); +export type OrchestrationThreadParentRelation = typeof OrchestrationThreadParentRelation.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -357,6 +379,7 @@ export const OrchestrationThread = Schema.Struct({ updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), deletedAt: Schema.NullOr(IsoDateTime), + parentRelation: Schema.optional(OrchestrationThreadParentRelation), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( Schema.withDecodingDefault(Effect.succeed([])), @@ -402,6 +425,7 @@ export const OrchestrationThreadShell = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + parentRelation: Schema.optional(OrchestrationThreadParentRelation), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -503,6 +527,7 @@ const ThreadCreateCommand = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + parentRelation: Schema.optional(OrchestrationThreadParentRelation), createdAt: IsoDateTime, }); @@ -532,6 +557,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + parentRelation: Schema.optional(OrchestrationThreadParentRelation), }); const ThreadRuntimeModeSetCommand = Schema.Struct({ @@ -724,6 +750,16 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadMessageUserAppendCommand = Schema.Struct({ + type: Schema.Literal("thread.message.user.append"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + text: TrimmedNonEmptyString, + turnId: Schema.optional(TurnId), + createdAt: IsoDateTime, +}); + const ThreadProposedPlanUpsertCommand = Schema.Struct({ type: Schema.Literal("thread.proposed-plan.upsert"), commandId: CommandId, @@ -766,6 +802,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, + ThreadMessageUserAppendCommand, ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, @@ -846,6 +883,7 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + parentRelation: Schema.optional(OrchestrationThreadParentRelation), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -872,6 +910,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + parentRelation: Schema.optional(OrchestrationThreadParentRelation), updatedAt: IsoDateTime, }); From d51e82fb5854039c169ea69b4d3312156f0cce84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Thu, 18 Jun 2026 18:04:20 +0100 Subject: [PATCH 02/15] Support resumed subagents as new parent work-log rows - Track subagent parent activity by child thread plus parent item id - Restart resumed child relations and append new prompt blocks - Update sidebar and work-log dedupe to keep resume blocks distinct --- SUBAGENTS.md | 7 +- .../Layers/ProviderRuntimeIngestion.test.ts | 133 ++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 54 ++++++- .../provider/Layers/CodexSessionRuntime.ts | 21 ++- .../src/components/chat/MessagesTimeline.tsx | 2 +- apps/web/src/session-logic.test.ts | 96 +++++++++++++ apps/web/src/session-logic.ts | 20 ++- 7 files changed, 312 insertions(+), 21 deletions(-) diff --git a/SUBAGENTS.md b/SUBAGENTS.md index 4e5025769a4..a08b72d121e 100644 --- a/SUBAGENTS.md +++ b/SUBAGENTS.md @@ -10,6 +10,7 @@ The current behavior is: - Active subagent threads appear in the sidebar nested under the direct parent that spawned them. - Completed, errored, interrupted, or stopped subagent threads are normally hidden from the sidebar but remain reachable from the parent conversation view. When a terminal subagent conversation is the active route, that subagent and any intermediate subagent ancestors are shown in the sidebar at their normal nested positions until the user navigates away. - A parent conversation view shows only parent-owned output and subagent summary blocks for direct children. +- When Codex resumes an existing subagent with a follow-up prompt, the parent conversation appends a new subagent summary block for that resumed activity while preserving the original block, and the same child thread appears in the sidebar as running again until the resumed turn reaches a terminal state. - A child conversation view shows the raw initial prompt that launched that child when Codex exposes it, followed by that child's output, tool calls, diffs, MCP calls, and other actions. Grandchildren appear only as blocks inside their direct parent child view. - Users cannot prompt or steer a subagent. The child view exposes stop control only while the child is running and a header button for returning to its direct parent conversation. - Stopping a parent does not automatically stop running children. Stopping a child explicitly targets that child. @@ -55,11 +56,11 @@ Review fixes added preservation guards so a normal root/default projection upser 1. Codex child identity is mapped into deterministic local child thread ids from `parentThreadId + providerThreadId`. This intentionally avoids using `parentItemId`, because Codex may emit multiple collab lifecycle/control items for the same child provider thread. -2. Codex collab lifecycle items now carry `subagentChildren` metadata on the parent activity payload. Each child reference includes the provider thread id, local child thread id, optional parent item id, and optional title seed. The parent UI uses this metadata to render the compact `Subagent - <title>` block. +2. Codex collab lifecycle items now carry `subagentChildren` metadata on the parent activity payload. Each child reference includes the provider thread id, local child thread id, optional parent item id, and optional title seed. Prompt-bearing `spawnAgent`, `resumeAgent`, and `sendInput` items start a new parent activity reference for the same child thread, while control-only `wait` and `closeAgent` items stay tied to the existing reference so they do not create duplicate blocks. The parent UI uses this metadata to render the compact `Subagent - <title>` block. 3. Codex collab lifecycle tracking preserves both the raw child prompt and the title seed. The raw prompt comes from the collab tool item `prompt` field and is used as displayable child-thread conversation history; the title seed remains the input for generated child titles and parent summary labeling. Whitespace-only raw prompts are ignored. -4. Child-thread creation and updates happen through orchestration ingestion. The server preserves the direct parent, root thread id, depth, parent activity sequence, title seed, provider thread id, and started timestamp. Synthetic child shells are created so hidden child routes can be opened before the full projection catches up. +4. Child-thread creation and updates happen through orchestration ingestion. The server preserves the direct parent, root thread id, depth, parent activity sequence, title seed, provider thread id, and started timestamp. When a terminal child is resumed from a new prompt-bearing parent activity, ingestion updates the existing child relation back to `running`, clears `completedAt`, records the new parent item id, and appends the follow-up prompt to the child conversation. Synthetic child shells are created so hidden child routes can be opened before the full projection catches up. 5. When Codex exposes a raw child prompt, ingestion appends it to the child thread as a non-streaming user message through an internal `thread.message.user.append` command. The prompt message uses stable ids derived from `childThreadId + parentItemId`, is not bound to the parent turn, and is appended even if the child shell already exists because Codex first emitted a started item without a prompt and later emitted a completed item with the concrete prompt. @@ -85,7 +86,7 @@ Review fixes added preservation guards so a normal root/default projection upser 6. Child conversation views replace the normal composer with a subagent control bar. Users cannot send prompts to a subagent. While a child is running, the available user control is stop. The chat header also includes an up-navigation button that opens the direct parent conversation. -7. Review fixes removed duplicate compact subagent rows from Codex control sequences such as `wait` and `closeAgent`. Parent timelines now de-dupe child reference rows when all referenced child thread ids were already represented. +7. Review fixes removed duplicate compact subagent rows from Codex control sequences such as `wait` and `closeAgent`. Parent timelines now de-dupe child reference rows by child thread id plus parent collab item id, so control repeats collapse while a resumed child with a new parent item id renders as a new appended block. 8. Shared subagent display helpers keep duration and fallback labels consistent across parent blocks and child controls. Terminal child rows with missing completion timestamps show an explicit unknown-duration fallback instead of implying successful completion, and active children use `working` wording instead of `running` wording. diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1f1d2c02dc2..69866f7fd3d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2694,6 +2694,139 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("marks a completed subagent child as running again when it is resumed", async () => { + const harness = await createHarness({ + textGeneration: { + generateThreadTitle: () => Effect.succeed({ title: "Reusable child" }), + }, + }); + const childThreadId = asThreadId("subagent-resume-test"); + + harness.emit({ + type: "item.started", + eventId: asEventId("evt-subagent-resume-initial-started"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-parent-initial"), + itemId: asItemId("parent-item-initial"), + payload: { + itemType: "collab_agent_tool_call", + status: "in_progress", + title: "Subagent", + detail: "Run initial check", + data: { + subagentChildren: [ + { + providerThreadId: "provider-child-resume-test", + childThreadId, + parentItemId: "parent-item-initial", + rawPrompt: "Run initial check", + titleSeed: "Run initial check", + }, + ], + }, + }, + }); + + await waitForThread( + harness.readModel, + (entry) => entry.parentRelation?.kind === "subagent", + 2000, + childThreadId, + ); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-subagent-resume-child-turn-started"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:01.000Z", + threadId: childThreadId, + turnId: asTurnId("turn-child-initial"), + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-subagent-resume-child-turn-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:02.000Z", + threadId: childThreadId, + turnId: asTurnId("turn-child-initial"), + status: "completed", + }); + + await waitForThread( + harness.readModel, + (entry) => + entry.parentRelation?.kind === "subagent" && entry.parentRelation.status === "completed", + 2000, + childThreadId, + ); + + harness.emit({ + type: "item.started", + eventId: asEventId("evt-subagent-resume-followup-started"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:01:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-parent-followup"), + itemId: asItemId("parent-item-followup"), + payload: { + itemType: "collab_agent_tool_call", + status: "in_progress", + title: "Subagent", + detail: "Run follow-up check", + data: { + subagentChildren: [ + { + providerThreadId: "provider-child-resume-test", + childThreadId, + parentItemId: "parent-item-followup", + rawPrompt: "Run follow-up check", + titleSeed: "Run follow-up check", + }, + ], + }, + }, + }); + + const childThread = await waitForThread( + harness.readModel, + (entry) => + entry.parentRelation?.kind === "subagent" && + entry.parentRelation.status === "running" && + entry.parentRelation.completedAt === null && + entry.parentRelation.parentItemId === "parent-item-followup" && + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.role === "user" && message.text === "Run follow-up check", + ), + 2000, + childThreadId, + ); + + expect(childThread.parentRelation).toMatchObject({ + kind: "subagent", + parentItemId: "parent-item-followup", + status: "running", + startedAt: "2026-01-01T00:01:00.000Z", + completedAt: null, + }); + expect(childThread.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "subagent-prompt:subagent-resume-test:parent-item-initial", + role: "user", + text: "Run initial check", + }), + expect.objectContaining({ + id: "subagent-prompt:subagent-resume-test:parent-item-followup", + role: "user", + text: "Run follow-up check", + }), + ]), + ); + }); + it("does not project a whitespace-only subagent prompt", async () => { const harness = await createHarness({ textGeneration: { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index e2812530862..953c6caf02a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -188,6 +188,25 @@ function sameId(left: string | null | undefined, right: string | null | undefine return left === right; } +function subagentParentRelationsEqual( + left: SubagentThreadParentRelation, + right: SubagentThreadParentRelation, +): boolean { + return ( + left.rootThreadId === right.rootThreadId && + left.parentThreadId === right.parentThreadId && + left.parentTurnId === right.parentTurnId && + left.parentItemId === right.parentItemId && + left.parentActivitySequence === right.parentActivitySequence && + left.providerThreadId === right.providerThreadId && + left.titleSeed === right.titleSeed && + left.depth === right.depth && + left.startedAt === right.startedAt && + left.completedAt === right.completedAt && + left.status === right.status + ); +} + function hasAssistantMessageForTurn( messages: ReadonlyArray<OrchestrationMessage>, turnId: TurnId, @@ -1406,20 +1425,33 @@ const make = Effect.gen(function* () { existingChild?.parentRelation?.kind === "subagent" ? existingChild.parentRelation : null; + const startsNewParentActivity = + existingRelation !== null && + existingRelation.parentItemId !== null && + existingRelation.parentItemId !== child.parentItemId; + const restartsRunningChild = + event.type === "item.started" && + (startsNewParentActivity || existingRelation?.status !== "running"); const parentRelation: SubagentThreadParentRelation = { kind: "subagent" as const, rootThreadId, parentThreadId: thread.id, - parentTurnId: existingRelation?.parentTurnId ?? eventTurnId ?? null, - parentItemId: existingRelation?.parentItemId ?? child.parentItemId, + parentTurnId: startsNewParentActivity + ? (eventTurnId ?? null) + : (existingRelation?.parentTurnId ?? eventTurnId ?? null), + parentItemId: startsNewParentActivity + ? child.parentItemId + : (existingRelation?.parentItemId ?? child.parentItemId), parentActivitySequence: existingRelation?.parentActivitySequence ?? runtimeEventSequence(event) ?? 0, providerThreadId: child.providerThreadId, - titleSeed: existingRelation?.titleSeed ?? child.titleSeed, + titleSeed: startsNewParentActivity + ? child.titleSeed + : (existingRelation?.titleSeed ?? child.titleSeed), depth: parentDepth + 1, - startedAt: existingRelation?.startedAt ?? now, - completedAt: existingRelation?.completedAt ?? null, - status: existingRelation?.status ?? "running", + startedAt: restartsRunningChild ? now : (existingRelation?.startedAt ?? now), + completedAt: restartsRunningChild ? null : (existingRelation?.completedAt ?? null), + status: restartsRunningChild ? "running" : (existingRelation?.status ?? "running"), }; if (!existingChild) { const title = "Subagent"; @@ -1469,6 +1501,16 @@ const make = Effect.gen(function* () { cwd: thread.worktreePath ?? process.cwd(), createdAt: now, }).pipe(Effect.forkScoped); + } else if ( + existingRelation && + !subagentParentRelationsEqual(existingRelation, parentRelation) + ) { + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* providerCommandId(event, "subagent-thread-parent-relation"), + threadId: child.childThreadId, + parentRelation, + }); } if (child.rawPrompt) { const childThreadIdText = String(child.childThreadId); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index ddc5f84a89e..fc2b1a858b4 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -631,6 +631,13 @@ function collabToolCallPrompt(item: CollabToolCallNotificationItem): string | un return typeof prompt === "string" ? trimNotificationText(prompt) : undefined; } +function isPromptBearingCollabToolCall(item: CollabToolCallNotificationItem): boolean { + if (!("tool" in item)) { + return false; + } + return item.tool === "spawnAgent" || item.tool === "resumeAgent" || item.tool === "sendInput"; +} + function rememberCollabReceiverTurns( collabReceiverTurns: Map<string, CollabReceiverInfo>, notification: CodexServerNotification, @@ -648,11 +655,17 @@ function rememberCollabReceiverTurns( const rawPrompt = collabToolCallPrompt(notification.params.item); const detail = collabToolCallDetail(notification.params.item); const parentItemId = ProviderItemId.make(notification.params.item.id); + const startsNewParentActivity = + isPromptBearingCollabToolCall(notification.params.item) && Boolean(rawPrompt || detail); for (const receiverThreadId of notification.params.item.receiverThreadIds) { const existing = collabReceiverTurns.get(receiverThreadId); collabReceiverTurns.set(receiverThreadId, { - parentTurnId: existing?.parentTurnId ?? parentTurnId, - parentItemId: existing?.parentItemId ?? parentItemId, + parentTurnId: startsNewParentActivity + ? parentTurnId + : (existing?.parentTurnId ?? parentTurnId), + parentItemId: startsNewParentActivity + ? parentItemId + : (existing?.parentItemId ?? parentItemId), providerThreadId: receiverThreadId, childThreadId: existing?.childThreadId ?? @@ -660,8 +673,8 @@ function rememberCollabReceiverTurns( parentThreadId, providerThreadId: receiverThreadId, }), - rawPrompt: rawPrompt ?? existing?.rawPrompt, - detail: existing?.detail ?? detail, + rawPrompt: startsNewParentActivity ? rawPrompt : (rawPrompt ?? existing?.rawPrompt), + detail: startsNewParentActivity ? detail : (existing?.detail ?? detail), }); } } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f281692d410..ff812e95374 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1782,7 +1782,7 @@ const SubagentWorkEntryRows = memo(function SubagentWorkEntryRows({ <div className="space-y-1 py-0.5"> {workEntry.subagentChildren?.map((child) => ( <SubagentWorkEntryButton - key={`${workEntry.id}:subagent:${child.threadId}`} + key={`${workEntry.id}:subagent:${child.threadId}:${child.parentItemId ?? ""}`} parentCreatedAt={workEntry.createdAt} threadId={child.threadId} {...((child.titleSeed ?? workEntry.subagentPrompt ?? workEntry.detail) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 2597805e1ca..338d01da2df 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1548,6 +1548,102 @@ describe("deriveWorkLogEntries", () => { }); }); + it("keeps a resumed subagent child block as a new parent work-log row", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "subagent-spawn", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.completed", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + detail: "Run initial check", + data: { + item: { + id: "call-spawn", + prompt: "Run initial check", + tool: "spawnAgent", + }, + subagentChildren: [ + { + childThreadId: "subagent-child-1", + parentItemId: "call-spawn", + titleSeed: "Run initial check", + }, + ], + }, + }, + }), + makeActivity({ + id: "subagent-wait", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + data: { + item: { + id: "call-wait", + prompt: null, + tool: "wait", + }, + subagentChildren: [ + { + childThreadId: "subagent-child-1", + parentItemId: "call-spawn", + }, + ], + }, + }, + }), + makeActivity({ + id: "subagent-resume", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.completed", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + detail: "Run follow-up check", + data: { + item: { + id: "call-resume", + prompt: "Run follow-up check", + tool: "resumeAgent", + }, + subagentChildren: [ + { + childThreadId: "subagent-child-1", + parentItemId: "call-resume", + titleSeed: "Run follow-up check", + }, + ], + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries).toHaveLength(2); + expect(entries.map((entry) => entry.id)).toEqual(["subagent-spawn", "subagent-resume"]); + expect(entries[0]?.subagentChildren).toEqual([ + { + threadId: "subagent-child-1", + parentItemId: "call-spawn", + titleSeed: "Run initial check", + }, + ]); + expect(entries[1]?.subagentChildren).toEqual([ + { + threadId: "subagent-child-1", + parentItemId: "call-resume", + titleSeed: "Run follow-up check", + }, + ]); + }); + it("uses completed read-file output previews and still collapses the same tool call", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5b2bac2eb00..f7e669e94f6 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -85,6 +85,7 @@ export interface WorkLogEntry { export interface SubagentWorkLogChild { threadId: ThreadId; + parentItemId?: string; titleSeed?: string; } @@ -807,7 +808,7 @@ function collapseDerivedWorkLogEntries( function dedupeSubagentChildWorkEntries( entries: ReadonlyArray<DerivedWorkLogEntry>, ): DerivedWorkLogEntry[] { - const seenChildThreadIds = new Set<string>(); + const seenChildActivityKeys = new Set<string>(); const deduped: DerivedWorkLogEntry[] = []; for (const entry of entries) { if (entry.itemType !== "collab_agent_tool_call" || !entry.subagentChildren?.length) { @@ -815,10 +816,11 @@ function dedupeSubagentChildWorkEntries( continue; } const unseenChildren = entry.subagentChildren.filter((child) => { - if (seenChildThreadIds.has(child.threadId)) { + const key = `${child.threadId}:${child.parentItemId ?? ""}`; + if (seenChildActivityKeys.has(key)) { return false; } - seenChildThreadIds.add(child.threadId); + seenChildActivityKeys.add(key); return true; }); if (unseenChildren.length === 0) { @@ -938,16 +940,18 @@ function mergeSubagentChildren( if (merged.length === 0) { return undefined; } - const byThreadId = new Map<ThreadId, SubagentWorkLogChild>(); + const byChildActivity = new Map<string, SubagentWorkLogChild>(); for (const child of merged) { - const existing = byThreadId.get(child.threadId); + const key = `${child.threadId}:${child.parentItemId ?? ""}`; + const existing = byChildActivity.get(key); const titleSeed = existing?.titleSeed ?? child.titleSeed; - byThreadId.set(child.threadId, { + byChildActivity.set(key, { threadId: child.threadId, + ...(child.parentItemId ? { parentItemId: child.parentItemId } : {}), ...(titleSeed ? { titleSeed } : {}), }); } - return [...byThreadId.values()]; + return [...byChildActivity.values()]; } function mergeTextOutput( @@ -1429,8 +1433,10 @@ function extractSubagentChildren( } seen.add(rawThreadId); const titleSeed = asTrimmedString(record?.titleSeed); + const parentItemId = asTrimmedString(record?.parentItemId); result.push({ threadId: ThreadId.make(rawThreadId), + ...(parentItemId ? { parentItemId } : {}), ...(titleSeed ? { titleSeed } : {}), }); } From 41fbb5a62d429cca1ffa9af63e496823a1b1bfbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Thu, 18 Jun 2026 18:31:22 +0100 Subject: [PATCH 03/15] Preserve subagent history across parent-item restarts - Treat follow-up completions as terminal subagent snapshots - Keep prior subagent status visible when a new parent item restarts the same thread - Update ingestion tests and timeline rendering to track parent item IDs --- .../Layers/ProviderRuntimeIngestion.test.ts | 6 +- .../Layers/ProviderRuntimeIngestion.ts | 4 +- .../src/components/chat/MessagesTimeline.tsx | 57 ++++++++++++++++++- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 69866f7fd3d..8eb195b79b9 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2763,8 +2763,8 @@ describe("ProviderRuntimeIngestion", () => { ); harness.emit({ - type: "item.started", - eventId: asEventId("evt-subagent-resume-followup-started"), + type: "item.completed", + eventId: asEventId("evt-subagent-resume-followup-completed"), provider: ProviderDriverKind.make("codex"), createdAt: "2026-01-01T00:01:00.000Z", threadId: asThreadId("thread-1"), @@ -2772,7 +2772,7 @@ describe("ProviderRuntimeIngestion", () => { itemId: asItemId("parent-item-followup"), payload: { itemType: "collab_agent_tool_call", - status: "in_progress", + status: "completed", title: "Subagent", detail: "Run follow-up check", data: { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 953c6caf02a..780af1f6800 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1430,8 +1430,8 @@ const make = Effect.gen(function* () { existingRelation.parentItemId !== null && existingRelation.parentItemId !== child.parentItemId; const restartsRunningChild = - event.type === "item.started" && - (startsNewParentActivity || existingRelation?.status !== "running"); + startsNewParentActivity || + (event.type === "item.started" && existingRelation?.status !== "running"); const parentRelation: SubagentThreadParentRelation = { kind: "subagent" as const, rootThreadId, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ff812e95374..65b4bfdbd76 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -37,6 +37,7 @@ import { formatTerminalSubagentStatusDuration, LiveSubagentDuration, subagentStatusToneClass, + type SubagentThreadStatus, } from "../../subagentDisplay"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import { @@ -1785,6 +1786,7 @@ const SubagentWorkEntryRows = memo(function SubagentWorkEntryRows({ key={`${workEntry.id}:subagent:${child.threadId}:${child.parentItemId ?? ""}`} parentCreatedAt={workEntry.createdAt} threadId={child.threadId} + {...(child.parentItemId ? { parentItemId: child.parentItemId } : {})} {...((child.titleSeed ?? workEntry.subagentPrompt ?? workEntry.detail) ? { titleSeed: child.titleSeed ?? workEntry.subagentPrompt ?? workEntry.detail } : {})} @@ -1796,6 +1798,7 @@ const SubagentWorkEntryRows = memo(function SubagentWorkEntryRows({ const SubagentWorkEntryButton = memo(function SubagentWorkEntryButton(props: { parentCreatedAt: string; + parentItemId?: string; threadId: ThreadId; titleSeed?: string; }) { @@ -1813,9 +1816,57 @@ const SubagentWorkEntryButton = memo(function SubagentWorkEntryButton(props: { const rawTitle = childShell?.title?.trim(); const title = rawTitle && rawTitle !== "Subagent" ? rawTitle : null; const displayTitle = title ? `Subagent - ${title}` : "Subagent"; - const status = relation?.status ?? null; - const startedAt = relation?.startedAt ?? props.parentCreatedAt; - const completedAt = relation?.completedAt ?? null; + const terminalSnapshotRef = useRef<{ + status: Exclude<SubagentThreadStatus, "running">; + startedAt: string; + completedAt: string | null; + } | null>(null); + const relationParentItemId = relation?.parentItemId ?? null; + const relationMatchesThisBlock = + !props.parentItemId || !relationParentItemId || relationParentItemId === props.parentItemId; + if (relation && relationMatchesThisBlock && relation.status !== "running") { + terminalSnapshotRef.current = { + status: relation.status, + startedAt: relation.startedAt, + completedAt: relation.completedAt, + }; + } + const parentCreatedAfterRelationCompleted = Boolean( + props.parentItemId && + relation && + relation.status !== "running" && + relation.completedAt && + Date.parse(props.parentCreatedAt) > Date.parse(relation.completedAt), + ); + const displayState = + relation && relationMatchesThisBlock + ? { + status: relation.status, + startedAt: relation.startedAt, + completedAt: relation.completedAt, + } + : parentCreatedAfterRelationCompleted + ? { + status: "running" as const, + startedAt: props.parentCreatedAt, + completedAt: null, + } + : terminalSnapshotRef.current + ? terminalSnapshotRef.current + : relation?.status === "running" + ? { + status: "completed" as const, + startedAt: props.parentCreatedAt, + completedAt: null, + } + : { + status: relation?.status ?? null, + startedAt: relation?.startedAt ?? props.parentCreatedAt, + completedAt: relation?.completedAt ?? null, + }; + const status = displayState.status; + const startedAt = displayState.startedAt; + const completedAt = displayState.completedAt; const statusDurationLabel = status === "running" ? ( <LiveSubagentDuration startedAt={startedAt} /> From 4d9369f278dbf4a39e6006b5b6ff2540c9039ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Thu, 18 Jun 2026 18:58:02 +0100 Subject: [PATCH 04/15] Dedupe resumed subagent blocks by turn - Track subagent child dedupe keys by parent turn when available - Keep resumed child blocks working in the timeline instead of completed - Add regression coverage for duplicate resumed subagent entries --- .../components/chat/MessagesTimeline.test.tsx | 126 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 8 +- apps/web/src/session-logic.test.ts | 69 ++++++++++ apps/web/src/session-logic.ts | 3 +- 4 files changed, 202 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 55007c1873e..c3a2f3414f4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,7 +1,7 @@ -import { EnvironmentId, MessageId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { createRef, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef } from "@legendapp/list/react"; vi.mock("@legendapp/list/react", async () => { @@ -46,6 +46,18 @@ vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff }; }); +const storeMock = vi.hoisted(() => ({ + state: { + environmentStateById: {}, + } as { + environmentStateById: Record<string, unknown>; + }, +})); + +vi.mock("../../store", () => ({ + useStore: <T,>(selector: (state: typeof storeMock.state) => T) => selector(storeMock.state), +})); + function matchMedia() { return { matches: false, @@ -92,6 +104,12 @@ beforeAll(() => { const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; +beforeEach(() => { + storeMock.state = { + environmentStateById: {}, + }; +}); + function buildProps() { return { isWorking: false, @@ -298,6 +316,110 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("</review_comment>"); }); + it("renders expandable subagent rows without status labels", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + <MessagesTimeline + {...buildProps()} + timelineEntries={[ + { + id: "entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Subagent", + tone: "tool", + itemType: "collab_agent_tool_call", + subagentPrompt: "Create one original haiku in English. Return only the haiku text.", + output: + "Rain lifts from the wires\nA window gathers pale dawn\nFootsteps bloom below", + }, + }, + ]} + />, + ); + + expect(markup).toContain("Subagent"); + expect(markup).toContain("Create one original haiku in English"); + expect(markup).not.toContain("Done"); + expect(markup).not.toContain("Running"); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand Subagent"'); + }); + + it("renders a deduped resumed subagent block as working when the parent turn matches", async () => { + const childThreadId = ThreadId.make("subagent-child-1"); + const parentTurnId = TurnId.make("turn-followup"); + storeMock.state = { + environmentStateById: { + [ACTIVE_THREAD_ENVIRONMENT_ID]: { + threadShellById: { + [childThreadId]: { + id: childThreadId, + title: "Say hi briefly", + parentRelation: { + kind: "subagent", + rootThreadId: ThreadId.make("thread-1"), + parentThreadId: ThreadId.make("thread-1"), + parentTurnId, + parentItemId: "call-send-input", + parentActivitySequence: 2, + providerThreadId: "provider-child-1", + titleSeed: "Say hi in German", + depth: 1, + startedAt: "2026-03-17T19:12:30.000Z", + completedAt: null, + status: "running", + }, + }, + }, + }, + }, + }; + + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + <MessagesTimeline + {...buildProps()} + activeTurnInProgress={true} + latestTurn={{ + turnId: parentTurnId, + state: "running", + startedAt: "2026-03-17T19:12:30.000Z", + completedAt: null, + }} + timelineEntries={[ + { + id: "entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:30.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:30.000Z", + turnId: parentTurnId, + label: "Subagent", + tone: "tool", + itemType: "collab_agent_tool_call", + subagentChildren: [ + { + threadId: childThreadId, + parentItemId: "call-resume", + titleSeed: "Say hi in German", + }, + ], + }, + }, + ]} + />, + ); + + expect(markup).toContain("Subagent - Say hi briefly"); + expect(markup).toContain("Working"); + expect(markup).not.toContain("Completed in"); + }); + it("renders file review comments as source code instead of diffs", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 65b4bfdbd76..532289d4a53 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1786,6 +1786,7 @@ const SubagentWorkEntryRows = memo(function SubagentWorkEntryRows({ key={`${workEntry.id}:subagent:${child.threadId}:${child.parentItemId ?? ""}`} parentCreatedAt={workEntry.createdAt} threadId={child.threadId} + {...(workEntry.turnId ? { parentTurnId: workEntry.turnId } : {})} {...(child.parentItemId ? { parentItemId: child.parentItemId } : {})} {...((child.titleSeed ?? workEntry.subagentPrompt ?? workEntry.detail) ? { titleSeed: child.titleSeed ?? workEntry.subagentPrompt ?? workEntry.detail } @@ -1799,6 +1800,7 @@ const SubagentWorkEntryRows = memo(function SubagentWorkEntryRows({ const SubagentWorkEntryButton = memo(function SubagentWorkEntryButton(props: { parentCreatedAt: string; parentItemId?: string; + parentTurnId?: TurnId; threadId: ThreadId; titleSeed?: string; }) { @@ -1822,8 +1824,12 @@ const SubagentWorkEntryButton = memo(function SubagentWorkEntryButton(props: { completedAt: string | null; } | null>(null); const relationParentItemId = relation?.parentItemId ?? null; + const relationParentTurnId = relation?.parentTurnId ?? null; const relationMatchesThisBlock = - !props.parentItemId || !relationParentItemId || relationParentItemId === props.parentItemId; + Boolean(props.parentTurnId && props.parentTurnId === relationParentTurnId) || + !props.parentItemId || + !relationParentItemId || + relationParentItemId === props.parentItemId; if (relation && relationMatchesThisBlock && relation.status !== "running") { terminalSnapshotRef.current = { status: relation.status, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 338d01da2df..de7a208f072 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1603,6 +1603,7 @@ describe("deriveWorkLogEntries", () => { createdAt: "2026-02-23T00:00:03.000Z", kind: "tool.completed", summary: "Subagent", + turnId: "turn-followup", payload: { itemType: "collab_agent_tool_call", title: "Subagent", @@ -1644,6 +1645,74 @@ describe("deriveWorkLogEntries", () => { ]); }); + it("drops duplicate resumed subagent child blocks within the same parent turn", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "subagent-resume", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.completed", + summary: "Subagent", + turnId: "turn-followup", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + detail: "Say hi in German", + data: { + item: { + id: "call-resume", + prompt: "Say hi in German", + tool: "resumeAgent", + }, + subagentChildren: [ + { + childThreadId: "subagent-child-1", + parentItemId: "call-resume", + titleSeed: "Say hi in German", + }, + ], + }, + }, + }), + makeActivity({ + id: "subagent-send-input", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Subagent", + turnId: "turn-followup", + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent", + detail: "Say hi in German", + data: { + item: { + id: "call-send-input", + prompt: "Say hi in German", + tool: "sendInput", + }, + subagentChildren: [ + { + childThreadId: "subagent-child-1", + parentItemId: "call-send-input", + titleSeed: "Say hi in German", + }, + ], + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries).toHaveLength(1); + expect(entries[0]?.id).toBe("subagent-resume"); + expect(entries[0]?.subagentChildren).toEqual([ + { + threadId: "subagent-child-1", + parentItemId: "call-resume", + titleSeed: "Say hi in German", + }, + ]); + }); + it("uses completed read-file output previews and still collapses the same tool call", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index f7e669e94f6..1af911f8916 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -816,7 +816,8 @@ function dedupeSubagentChildWorkEntries( continue; } const unseenChildren = entry.subagentChildren.filter((child) => { - const key = `${child.threadId}:${child.parentItemId ?? ""}`; + const activityScope = entry.turnId ?? child.parentItemId ?? ""; + const key = `${child.threadId}:${activityScope}`; if (seenChildActivityKeys.has(key)) { return false; } From 7ea4960937c3e11ff1d41969e494d453070bd300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Thu, 18 Jun 2026 19:02:47 +0100 Subject: [PATCH 05/15] Remove duplicate subagent timeline test --- .../components/chat/MessagesTimeline.test.tsx | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c3a2f3414f4..a0424f53d75 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -316,39 +316,6 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("</review_comment>"); }); - it("renders expandable subagent rows without status labels", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - <MessagesTimeline - {...buildProps()} - timelineEntries={[ - { - id: "entry-1", - kind: "work", - createdAt: "2026-03-17T19:12:28.000Z", - entry: { - id: "work-1", - createdAt: "2026-03-17T19:12:28.000Z", - label: "Subagent", - tone: "tool", - itemType: "collab_agent_tool_call", - subagentPrompt: "Create one original haiku in English. Return only the haiku text.", - output: - "Rain lifts from the wires\nA window gathers pale dawn\nFootsteps bloom below", - }, - }, - ]} - />, - ); - - expect(markup).toContain("Subagent"); - expect(markup).toContain("Create one original haiku in English"); - expect(markup).not.toContain("Done"); - expect(markup).not.toContain("Running"); - expect(markup).toContain('aria-expanded="false"'); - expect(markup).toContain('aria-label="Expand Subagent"'); - }); - it("renders a deduped resumed subagent block as working when the parent turn matches", async () => { const childThreadId = ThreadId.make("subagent-child-1"); const parentTurnId = TurnId.make("turn-followup"); From ffe2d0b684dc0e3430806003b67fc34f0d199741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Fri, 19 Jun 2026 19:30:16 +0100 Subject: [PATCH 06/15] Cascade subagent thread lifecycle --- SUBAGENTS.md | 6 +- .../src/orchestration/decider.delete.test.ts | 185 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 99 +++++++++- 3 files changed, 285 insertions(+), 5 deletions(-) diff --git a/SUBAGENTS.md b/SUBAGENTS.md index a08b72d121e..9d8163d9d75 100644 --- a/SUBAGENTS.md +++ b/SUBAGENTS.md @@ -68,7 +68,7 @@ Review fixes added preservation guards so a normal root/default projection upser 7. Child stop/interrupt handling routes through the provider-bound root session while targeting the selected child thread/turn. Parent stop remains scoped to the requested parent thread and does not cascade to active children. -8. Completed child detail remains tied to the root parent lifecycle. The web action layer dispatches archive/delete lifecycle actions for the root and collected descendant subagent threads, with root actions last. Delete also attempts to stop and close terminal state for involved lifecycle thread ids. +8. Completed child detail remains tied to the root parent lifecycle. The orchestration decider cascades root parent archive/delete actions through active descendant subagent threads before applying the parent event, with deepest children first. Force-deleting a project delegates through lifecycle root threads so descendant subagents are deleted once through their parent root instead of being planned twice. The web action layer still performs stop, terminal-close, navigation, draft cleanup, and currently materialized descendant cleanup around those server lifecycle commands. 9. Unsupported providers keep the previous fallback behavior. No durable nested-thread behavior should be inferred for Claude, Cursor, OpenCode, or other providers until their event streams expose enough lineage to make child routing reliable. @@ -107,7 +107,7 @@ Review fixes added preservation guards so a normal root/default projection upser The implementation and review fixes have been covered by focused automated tests and Playwright regression checks: -- Server tests cover Codex subagent ingestion, child terminal status, parent-relation persistence, projection upsert preservation, and child stop/interrupt routing through the provider-bound root session. +- Server tests cover Codex subagent ingestion, child terminal status, parent-relation persistence, projection upsert preservation, child stop/interrupt routing through the provider-bound root session, and archive/delete lifecycle cascades through subagent descendants. - Server tests cover raw subagent prompt projection into child threads, including start-then-complete late prompt updates and whitespace-only prompt suppression. - Web tests cover sidebar/thread state behavior, duplicate parent subagent control-row removal, child composer suppression, subagent stop control behavior, and duration fallback labels. - Playwright checked Codex subagent behavior with marker prompts: before the prompt projection fix, the child view showed the output marker but not the initial prompt marker; after the fix, the child view showed the initial prompt marker followed by the output marker. Earlier Playwright coverage also checked that the parent showed exactly one compact subagent block, child output/actions did not leak into the parent, the child view was reachable from the parent block, the child view showed the child command/output, and the child view did not expose a prompt composer. @@ -127,7 +127,7 @@ pnpm exec vp run lint:mobile ## Remaining Risks And Hardening Items -1. Root lifecycle cascade should be hardened server-side for hidden descendants that are not materialized in the current client environment. The client currently dispatches archive/delete for collected descendants, but a database/root-thread cascade would be more robust across reconnects and multi-client gaps. +1. Archive/delete lifecycle is now planned server-side for active descendant subagents, but archived descendant visibility and historical tombstone browsing should still be audited across reconnects and multi-client gaps. 2. Diff/checkpoint aggregation is intentionally parent-visible at the workspace level, but the exact UI for aggregate root diffs should be audited separately. Per-action diff rendering is scoped to the child timeline. diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts index fea36b5717f..3de593632d4 100644 --- a/apps/server/src/orchestration/decider.delete.test.ts +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -7,6 +7,8 @@ import { type OrchestrationCommand, type OrchestrationEvent, ProviderInstanceId, + ProviderItemId, + TurnId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -19,6 +21,8 @@ const asCommandId = (value: string): CommandId => CommandId.make(value); const asEventId = (value: string): EventId => EventId.make(value); const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asThreadId = (value: string): ThreadId => ThreadId.make(value); +const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); const seedReadModel = Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; @@ -102,6 +106,96 @@ const seedReadModel = Effect.gen(function* () { }); }); +const seedReadModelWithSubagents = Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const withRoots = yield* seedReadModel; + const childThreadId = asThreadId("thread-delete-1-child"); + const grandchildThreadId = asThreadId("thread-delete-1-grandchild"); + const withChild = yield* projectEvent(withRoots, { + sequence: 4, + eventId: asEventId("evt-thread-create-1-child"), + aggregateKind: "thread", + aggregateId: childThreadId, + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create-1-child"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create-1-child"), + metadata: {}, + payload: { + threadId: childThreadId, + projectId: asProjectId("project-delete"), + title: "Thread Delete 1 Child", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + parentRelation: { + kind: "subagent", + rootThreadId: asThreadId("thread-delete-1"), + parentThreadId: asThreadId("thread-delete-1"), + parentTurnId: asTurnId("turn-delete-1"), + parentItemId: asProviderItemId("item-delete-1"), + parentActivitySequence: 1, + providerThreadId: "provider-thread-delete-1-child", + titleSeed: "Child", + depth: 1, + startedAt: now, + completedAt: null, + status: "running", + }, + createdAt: now, + updatedAt: now, + }, + }); + + return yield* projectEvent(withChild, { + sequence: 5, + eventId: asEventId("evt-thread-create-1-grandchild"), + aggregateKind: "thread", + aggregateId: grandchildThreadId, + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create-1-grandchild"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create-1-grandchild"), + metadata: {}, + payload: { + threadId: grandchildThreadId, + projectId: asProjectId("project-delete"), + title: "Thread Delete 1 Grandchild", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + parentRelation: { + kind: "subagent", + rootThreadId: asThreadId("thread-delete-1"), + parentThreadId: childThreadId, + parentTurnId: asTurnId("turn-delete-1-child"), + parentItemId: asProviderItemId("item-delete-1-child"), + parentActivitySequence: 2, + providerThreadId: "provider-thread-delete-1-grandchild", + titleSeed: "Grandchild", + depth: 2, + startedAt: now, + completedAt: null, + status: "running", + }, + createdAt: now, + updatedAt: now, + }, + }); +}); + type PlannedEvent = Omit<OrchestrationEvent, "sequence">; function normalizeDeleteEvent(event: PlannedEvent | ReadonlyArray<PlannedEvent>) { @@ -136,6 +230,31 @@ function normalizeDeleteEvent(event: PlannedEvent | ReadonlyArray<PlannedEvent>) }); } +function normalizeThreadLifecycleEvents(event: PlannedEvent | ReadonlyArray<PlannedEvent>) { + const events = Array.isArray(event) ? event : [event]; + return events.map((entry) => { + switch (entry.type) { + case "thread.deleted": + return { + type: entry.type, + threadId: entry.payload.threadId, + }; + case "thread.archived": + return { + type: entry.type, + threadId: entry.payload.threadId, + }; + case "project.deleted": + return { + type: entry.type, + projectId: entry.payload.projectId, + }; + default: + return { type: entry.type }; + } + }); +} + it.layer(NodeServices.layer)("decider deletion flows", (it) => { it.effect("rejects deleting a non-empty project without force", () => Effect.gen(function* () { @@ -214,4 +333,70 @@ it.layer(NodeServices.layer)("decider deletion flows", (it) => { expect(normalizeDeleteEvent(forcedResult)).toEqual(normalizeDeleteEvent(sequentialEvents)); }), ); + + it.effect("deletes subagent descendants before deleting their parent thread", () => + Effect.gen(function* () { + const readModel = yield* seedReadModelWithSubagents; + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.delete", + commandId: asCommandId("cmd-thread-delete-cascade"), + threadId: asThreadId("thread-delete-1"), + }, + readModel, + }); + + expect(normalizeThreadLifecycleEvents(result)).toEqual([ + { type: "thread.deleted", threadId: asThreadId("thread-delete-1-grandchild") }, + { type: "thread.deleted", threadId: asThreadId("thread-delete-1-child") }, + { type: "thread.deleted", threadId: asThreadId("thread-delete-1") }, + ]); + }), + ); + + it.effect("archives subagent descendants before archiving their parent thread", () => + Effect.gen(function* () { + const readModel = yield* seedReadModelWithSubagents; + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.archive", + commandId: asCommandId("cmd-thread-archive-cascade"), + threadId: asThreadId("thread-delete-1"), + }, + readModel, + }); + + expect(normalizeThreadLifecycleEvents(result)).toEqual([ + { type: "thread.archived", threadId: asThreadId("thread-delete-1-grandchild") }, + { type: "thread.archived", threadId: asThreadId("thread-delete-1-child") }, + { type: "thread.archived", threadId: asThreadId("thread-delete-1") }, + ]); + }), + ); + + it.effect("force-deletes subagent descendants once when deleting a project", () => + Effect.gen(function* () { + const readModel = yield* seedReadModelWithSubagents; + + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.delete", + commandId: asCommandId("cmd-project-delete-subagents"), + projectId: asProjectId("project-delete"), + force: true, + }, + readModel, + }); + + expect(normalizeThreadLifecycleEvents(result)).toEqual([ + { type: "thread.deleted", threadId: asThreadId("thread-delete-1-grandchild") }, + { type: "thread.deleted", threadId: asThreadId("thread-delete-1-child") }, + { type: "thread.deleted", threadId: asThreadId("thread-delete-1") }, + { type: "thread.deleted", threadId: asThreadId("thread-delete-2") }, + { type: "project.deleted", projectId: asProjectId("project-delete") }, + ]); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index ab9d00f9d83..f1beda863fc 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -3,6 +3,7 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + type OrchestrationThread, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -23,6 +24,64 @@ import { projectEvent } from "./projector.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +type ThreadDeleteCommand = Extract<OrchestrationCommand, { type: "thread.delete" }>; +type ThreadArchiveCommand = Extract<OrchestrationCommand, { type: "thread.archive" }>; + +function compareSubagentLifecycleOrder(left: OrchestrationThread, right: OrchestrationThread) { + const leftDepth = left.parentRelation?.kind === "subagent" ? left.parentRelation.depth : 0; + const rightDepth = right.parentRelation?.kind === "subagent" ? right.parentRelation.depth : 0; + if (leftDepth !== rightDepth) { + return rightDepth - leftDepth; + } + return left.id.localeCompare(right.id); +} + +function listActiveSubagentDescendants( + readModel: OrchestrationReadModel, + parentThreadId: OrchestrationThread["id"], +): readonly OrchestrationThread[] { + const descendants: OrchestrationThread[] = []; + const pendingParentThreadIds = new Set<OrchestrationThread["id"]>([parentThreadId]); + let changed = true; + + while (changed) { + changed = false; + for (const thread of readModel.threads) { + if (thread.deletedAt !== null || thread.parentRelation?.kind !== "subagent") { + continue; + } + if (!pendingParentThreadIds.has(thread.parentRelation.parentThreadId)) { + continue; + } + if (pendingParentThreadIds.has(thread.id)) { + continue; + } + descendants.push(thread); + pendingParentThreadIds.add(thread.id); + changed = true; + } + } + + return descendants.toSorted(compareSubagentLifecycleOrder); +} + +function listProjectLifecycleRootThreads( + readModel: OrchestrationReadModel, + projectId: OrchestrationThread["projectId"], +): readonly OrchestrationThread[] { + const activeThreads = listThreadsByProjectId(readModel, projectId).filter( + (thread) => thread.deletedAt === null, + ); + const activeThreadIds = new Set(activeThreads.map((thread) => thread.id)); + + return activeThreads.filter((thread) => { + if (thread.parentRelation?.kind !== "subagent") { + return true; + } + return !activeThreadIds.has(thread.parentRelation.parentThreadId); + }); +} + function withEventBase( input: Pick<OrchestrationCommand, "commandId"> & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -176,11 +235,16 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); } if (activeThreads.length > 0) { + const lifecycleRootThreads = listProjectLifecycleRootThreads(readModel, command.projectId); + const threadsToDelete = + lifecycleRootThreads.length > 0 + ? lifecycleRootThreads + : activeThreads.toSorted(compareSubagentLifecycleOrder); return yield* decideCommandSequence({ readModel, commands: [ - ...activeThreads.map( - (thread): Extract<OrchestrationCommand, { type: "thread.delete" }> => ({ + ...threadsToDelete.map( + (thread): ThreadDeleteCommand => ({ type: "thread.delete", commandId: command.commandId, threadId: thread.id, @@ -254,6 +318,22 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + const descendantDeleteCommands = listActiveSubagentDescendants( + readModel, + command.threadId, + ).map( + (thread): ThreadDeleteCommand => ({ + type: "thread.delete", + commandId: command.commandId, + threadId: thread.id, + }), + ); + if (descendantDeleteCommands.length > 0) { + return yield* decideCommandSequence({ + readModel, + commands: [...descendantDeleteCommands, command], + }); + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -276,6 +356,21 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + const descendantArchiveCommands = listActiveSubagentDescendants(readModel, command.threadId) + .filter((thread) => thread.archivedAt === null) + .map( + (thread): ThreadArchiveCommand => ({ + type: "thread.archive", + commandId: command.commandId, + threadId: thread.id, + }), + ); + if (descendantArchiveCommands.length > 0) { + return yield* decideCommandSequence({ + readModel, + commands: [...descendantArchiveCommands, command], + }); + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ From f744946f16b1858f2bd52b702949f97a9ab31a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Fri, 19 Jun 2026 23:09:10 +0100 Subject: [PATCH 07/15] Document subagent thread retention after upstream merge --- SUBAGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/SUBAGENTS.md b/SUBAGENTS.md index 9d8163d9d75..6198c200803 100644 --- a/SUBAGENTS.md +++ b/SUBAGENTS.md @@ -90,6 +90,8 @@ Review fixes added preservation guards so a normal root/default projection upser 8. Shared subagent display helpers keep duration and fallback labels consistent across parent blocks and child controls. Terminal child rows with missing completion timestamps show an explicit unknown-duration fallback instead of implying successful completion, and active children use `working` wording instead of `running` wording. +9. Thread list and detail state now share the client-runtime idle retention TTL, so short route/sidebar unmount gaps should not immediately drop hidden child-thread detail or active subagent sidebar state. + ## Decisions Captured - Persistence retention: child detail is tied to parent lifecycle. @@ -110,6 +112,7 @@ The implementation and review fixes have been covered by focused automated tests - Server tests cover Codex subagent ingestion, child terminal status, parent-relation persistence, projection upsert preservation, child stop/interrupt routing through the provider-bound root session, and archive/delete lifecycle cascades through subagent descendants. - Server tests cover raw subagent prompt projection into child threads, including start-then-complete late prompt updates and whitespace-only prompt suppression. - Web tests cover sidebar/thread state behavior, duplicate parent subagent control-row removal, child composer suppression, subagent stop control behavior, and duration fallback labels. +- Client-runtime tests cover shared idle retention for stream-backed thread state across short subscriber gaps. - Playwright checked Codex subagent behavior with marker prompts: before the prompt projection fix, the child view showed the output marker but not the initial prompt marker; after the fix, the child view showed the initial prompt marker followed by the output marker. Earlier Playwright coverage also checked that the parent showed exactly one compact subagent block, child output/actions did not leak into the parent, the child view was reachable from the parent block, the child view showed the child command/output, and the child view did not expose a prompt composer. Current completion gates for this repo remain: From ab7e59b8cf8d917daa87faab8a0102bfd621f05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Sat, 20 Jun 2026 13:48:02 +0100 Subject: [PATCH 08/15] Fix subagent threading review issues --- SUBAGENTS.md | 10 +- .../Layers/ProviderCommandReactor.test.ts | 81 +++++++++++ .../Layers/ProviderCommandReactor.ts | 10 ++ .../Layers/ProviderRuntimeIngestion.test.ts | 54 ++++++++ .../Layers/ProviderRuntimeIngestion.ts | 128 +++++++++++++++++- .../src/provider/Layers/CodexAdapter.test.ts | 7 +- .../src/provider/Layers/CodexAdapter.ts | 34 ++++- .../provider/Layers/CodexSessionRuntime.ts | 22 ++- apps/web/src/components/ChatView.tsx | 4 + apps/web/src/components/Sidebar.tsx | 56 +++++++- apps/web/src/session-logic.test.ts | 12 +- apps/web/src/session-logic.ts | 2 +- 12 files changed, 392 insertions(+), 28 deletions(-) diff --git a/SUBAGENTS.md b/SUBAGENTS.md index 6198c200803..5894a116095 100644 --- a/SUBAGENTS.md +++ b/SUBAGENTS.md @@ -60,13 +60,13 @@ Review fixes added preservation guards so a normal root/default projection upser 3. Codex collab lifecycle tracking preserves both the raw child prompt and the title seed. The raw prompt comes from the collab tool item `prompt` field and is used as displayable child-thread conversation history; the title seed remains the input for generated child titles and parent summary labeling. Whitespace-only raw prompts are ignored. -4. Child-thread creation and updates happen through orchestration ingestion. The server preserves the direct parent, root thread id, depth, parent activity sequence, title seed, provider thread id, and started timestamp. When a terminal child is resumed from a new prompt-bearing parent activity, ingestion updates the existing child relation back to `running`, clears `completedAt`, records the new parent item id, and appends the follow-up prompt to the child conversation. Synthetic child shells are created so hidden child routes can be opened before the full projection catches up. +4. Child-thread creation and updates happen through orchestration ingestion. The server preserves the direct parent, root thread id, depth, parent activity sequence, title seed, provider thread id, and started timestamp. When a terminal child is resumed from a new prompt-bearing parent activity, ingestion updates the existing child relation back to `running`, clears `completedAt`, records the new parent item id, and appends the follow-up prompt to the child conversation. Synthetic child shells are created so hidden child routes can be opened before the full projection catches up, and child runtime events that arrive with parent-collab metadata can synthesize the missing child shell before their output or tool activity is ingested. 5. When Codex exposes a raw child prompt, ingestion appends it to the child thread as a non-streaming user message through an internal `thread.message.user.append` command. The prompt message uses stable ids derived from `childThreadId + parentItemId`, is not bound to the parent turn, and is appended even if the child shell already exists because Codex first emitted a started item without a prompt and later emitted a completed item with the concrete prompt. 6. Child terminal status is derived from child lifecycle events, not from the parent collab item alone. Terminal updates apply only while the relation is still `running`, which prevents later `session.exited` events from overwriting a more specific `completed`, `errored`, `interrupted`, or `stopped` result. -7. Child stop/interrupt handling routes through the provider-bound root session while targeting the selected child thread/turn. Parent stop remains scoped to the requested parent thread and does not cascade to active children. +7. Child stop/interrupt handling routes through the provider-bound root session while targeting the selected child thread/turn. Parent stop remains scoped to the requested parent thread and does not cascade to active children. If a child stop request cannot identify an active child turn, the server records an interrupt failure on the child instead of falling back to the root session active turn or marking the child stopped. 8. Completed child detail remains tied to the root parent lifecycle. The orchestration decider cascades root parent archive/delete actions through active descendant subagent threads before applying the parent event, with deepest children first. Force-deleting a project delegates through lifecycle root threads so descendant subagents are deleted once through their parent root instead of being planned twice. The web action layer still performs stop, terminal-close, navigation, draft cleanup, and currently materialized descendant cleanup around those server lifecycle commands. @@ -86,7 +86,7 @@ Review fixes added preservation guards so a normal root/default projection upser 6. Child conversation views replace the normal composer with a subagent control bar. Users cannot send prompts to a subagent. While a child is running, the available user control is stop. The chat header also includes an up-navigation button that opens the direct parent conversation. -7. Review fixes removed duplicate compact subagent rows from Codex control sequences such as `wait` and `closeAgent`. Parent timelines now de-dupe child reference rows by child thread id plus parent collab item id, so control repeats collapse while a resumed child with a new parent item id renders as a new appended block. +7. Review fixes removed duplicate compact subagent rows from Codex control sequences such as `wait` and `closeAgent`. Parent timelines now de-dupe child reference rows by child thread id plus parent collab item id, so control repeats collapse while each prompt-bearing resumed child activity with a new parent item id renders as a new appended block, even when multiple activities happen in the same parent turn. 8. Shared subagent display helpers keep duration and fallback labels consistent across parent blocks and child controls. Terminal child rows with missing completion timestamps show an explicit unknown-duration fallback instead of implying successful completion, and active children use `working` wording instead of `running` wording. @@ -109,9 +109,9 @@ Review fixes added preservation guards so a normal root/default projection upser The implementation and review fixes have been covered by focused automated tests and Playwright regression checks: -- Server tests cover Codex subagent ingestion, child terminal status, parent-relation persistence, projection upsert preservation, child stop/interrupt routing through the provider-bound root session, and archive/delete lifecycle cascades through subagent descendants. +- Server tests cover Codex subagent ingestion, parent-collab child shell synthesis, child terminal status, parent-relation persistence, projection upsert preservation, child stop/interrupt routing through the provider-bound root session without root-turn fallback, and archive/delete lifecycle cascades through subagent descendants. - Server tests cover raw subagent prompt projection into child threads, including start-then-complete late prompt updates and whitespace-only prompt suppression. -- Web tests cover sidebar/thread state behavior, duplicate parent subagent control-row removal, child composer suppression, subagent stop control behavior, and duration fallback labels. +- Web tests cover sidebar/thread state behavior, duplicate parent subagent control-row removal, same-turn resumed child activity rows, child composer suppression, subagent stop control behavior, and duration fallback labels. - Client-runtime tests cover shared idle retention for stream-backed thread state across short subscriber gaps. - Playwright checked Codex subagent behavior with marker prompts: before the prompt projection fix, the child view showed the output marker but not the initial prompt marker; after the fix, the child view showed the initial prompt marker followed by the output marker. Earlier Playwright coverage also checked that the parent showed exactly one compact subagent block, child output/actions did not leak into the parent, the child view was reachable from the parent block, the child view showed the child command/output, and the child view did not expose a prompt composer. diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 0d31010195d..bbb9b0c0862 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1718,6 +1718,87 @@ describe("ProviderCommandReactor", () => { }); }); + it("does not interrupt the root session when a subagent has no active child turn", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const childThreadId = ThreadId.make("subagent-thread-without-turn"); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-root-session-set-no-child-turn"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("root-active-turn"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-subagent-thread-create-no-child-turn"), + threadId: childThreadId, + projectId: asProjectId("project-1"), + title: "Subagent Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + parentRelation: { + kind: "subagent", + rootThreadId: ThreadId.make("thread-1"), + parentThreadId: ThreadId.make("thread-1"), + parentTurnId: asTurnId("root-active-turn"), + parentItemId: asProviderItemId("parent-item-no-child-turn"), + parentActivitySequence: 0, + providerThreadId: "provider-child-thread-no-turn", + titleSeed: "Investigate child task", + depth: 1, + startedAt: now, + completedAt: null, + status: "running", + }, + createdAt: now, + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-subagent-turn-interrupt-no-child-turn"), + threadId: childThreadId, + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + const child = readModel.threads.find((entry) => entry.id === childThreadId); + return ( + child?.activities.some((activity) => activity.kind === "provider.turn.interrupt.failed") ?? + false + ); + }); + expect(harness.interruptTurn).not.toHaveBeenCalled(); + const readModel = await harness.readModel(); + const child = readModel.threads.find((entry) => entry.id === childThreadId); + expect(child?.parentRelation).toMatchObject({ + kind: "subagent", + status: "running", + }); + }); + it("starts a fresh session when only projected session state exists", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 01ef5de8741..254aaf1b4ea 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -886,6 +886,16 @@ const make = Effect.gen(function* () { const childTurnId = subagentRelation ? (event.payload.turnId ?? thread.latestTurn?.turnId) : undefined; + if (subagentRelation && !childTurnId) { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.interrupt.failed", + summary: "Provider turn interrupt failed", + detail: "No active subagent turn is available to interrupt.", + turnId: null, + createdAt: event.payload.createdAt, + }); + } yield* providerService.interruptTurn({ threadId: providerThread.id, ...(childTurnId ? { turnId: childTurnId } : {}), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 8eb195b79b9..afb7b12c934 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2599,6 +2599,60 @@ describe("ProviderRuntimeIngestion", () => { }); }); + it("creates a child shell before ingesting child events with parent collab metadata", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const childThreadId = asThreadId("subagent-early-shell-test"); + + harness.emit({ + type: "item.updated", + eventId: asEventId("evt-subagent-early-child-output"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: childThreadId, + turnId: asTurnId("child-turn-early"), + itemId: asItemId("child-command-early"), + payload: { + itemType: "command_execution", + status: "in_progress", + title: "Ran command", + detail: "echo child", + data: { + parentCollab: { + parentThreadId: "thread-1", + providerThreadId: "provider-child-early", + childThreadId, + parentTurnId: "turn-parent-early", + itemId: "parent-item-early", + detail: "Inspect early output", + }, + }, + }, + }); + + const childThread = await waitForThread( + harness.readModel, + (entry) => + entry.parentRelation?.kind === "subagent" && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.updated", + ), + 2000, + childThreadId, + ); + + expect(childThread.parentRelation).toMatchObject({ + kind: "subagent", + parentThreadId: "thread-1", + providerThreadId: "provider-child-early", + parentItemId: "parent-item-early", + status: "running", + }); + expect(childThread.activities.some((activity) => activity.summary === "Ran command")).toBe( + true, + ); + }); + it("projects the subagent launch prompt as the child thread's initial user message", async () => { const rawPrompt = "CHILD_INITIAL_PROMPT_MARKER_TEST: Do not edit files. Return CHILD_OUTPUT_MARKER_TEST."; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 780af1f6800..d89e8b76ef8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -54,6 +54,15 @@ interface RuntimeSubagentChild { readonly titleSeed: string | null; } +interface RuntimeSubagentParentCollab { + readonly parentThreadId: ThreadId; + readonly childThreadId: ThreadId; + readonly providerThreadId: string; + readonly parentTurnId: TurnId | null; + readonly parentItemId: ProviderItemId; + readonly titleSeed: string | null; +} + type SubagentThreadParentRelation = Extract< OrchestrationThreadParentRelation, { kind: "subagent" } @@ -152,6 +161,52 @@ function readRuntimeSubagentChildren( }); } +function readRuntimeSubagentParentCollab( + event: ProviderRuntimeEvent, +): RuntimeSubagentParentCollab | null { + const data = asRecord(asRecord(event.payload)?.data); + const parentCollab = asRecord(data?.parentCollab); + if (!parentCollab) { + return null; + } + const parentThreadId = + typeof parentCollab.parentThreadId === "string" && parentCollab.parentThreadId.trim().length > 0 + ? ThreadId.make(parentCollab.parentThreadId) + : null; + const childThreadId = + typeof parentCollab.childThreadId === "string" && parentCollab.childThreadId.trim().length > 0 + ? ThreadId.make(parentCollab.childThreadId) + : event.threadId; + const providerThreadId = + typeof parentCollab.providerThreadId === "string" && + parentCollab.providerThreadId.trim().length > 0 + ? parentCollab.providerThreadId + : null; + const parentItemId = + typeof parentCollab.itemId === "string" && parentCollab.itemId.trim().length > 0 + ? ProviderItemId.make(parentCollab.itemId) + : null; + if (!parentThreadId || !childThreadId || !providerThreadId || !parentItemId) { + return null; + } + const parentTurnId = + typeof parentCollab.parentTurnId === "string" && parentCollab.parentTurnId.trim().length > 0 + ? TurnId.make(parentCollab.parentTurnId) + : null; + const titleSeed = + typeof parentCollab.detail === "string" && parentCollab.detail.trim().length > 0 + ? parentCollab.detail.trim() + : null; + return { + parentThreadId, + childThreadId, + providerThreadId, + parentTurnId, + parentItemId, + titleSeed, + }; +} + function runtimeEventSequence(event: ProviderRuntimeEvent): number | undefined { const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; return eventWithSequence.sessionSequence; @@ -1391,8 +1446,77 @@ const make = Effect.gen(function* () { const processRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { - const thread = yield* resolveThreadShell(event.threadId); - if (!thread) return; + let thread = yield* resolveThreadShell(event.threadId); + if (!thread) { + const parentCollab = readRuntimeSubagentParentCollab(event); + const parentThread = parentCollab + ? yield* resolveThreadShell(parentCollab.parentThreadId) + : null; + if (!parentCollab || !parentThread) { + return; + } + const rootThreadId = + parentThread.parentRelation?.kind === "subagent" + ? parentThread.parentRelation.rootThreadId + : parentThread.id; + const parentDepth = + parentThread.parentRelation?.kind === "subagent" ? parentThread.parentRelation.depth : 0; + const parentRelation: SubagentThreadParentRelation = { + kind: "subagent", + rootThreadId, + parentThreadId: parentThread.id, + parentTurnId: parentCollab.parentTurnId, + parentItemId: parentCollab.parentItemId, + parentActivitySequence: runtimeEventSequence(event) ?? 0, + providerThreadId: parentCollab.providerThreadId, + titleSeed: parentCollab.titleSeed, + depth: parentDepth + 1, + startedAt: event.createdAt, + completedAt: null, + status: "running", + }; + thread = { + id: parentCollab.childThreadId, + projectId: parentThread.projectId, + title: "Subagent", + modelSelection: parentThread.modelSelection, + runtimeMode: parentThread.runtimeMode, + interactionMode: parentThread.interactionMode, + branch: parentThread.branch, + worktreePath: parentThread.worktreePath, + latestTurn: null, + createdAt: event.createdAt, + updatedAt: event.createdAt, + archivedAt: null, + parentRelation, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; + yield* Ref.update(syntheticChildShellById, (current) => { + const next = new Map(current); + next.set(parentCollab.childThreadId, thread!); + return next; + }); + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: CommandId.make( + `provider:subagent-thread-create:${parentCollab.childThreadId}`, + ), + threadId: parentCollab.childThreadId, + projectId: parentThread.projectId, + title: "Subagent", + modelSelection: parentThread.modelSelection, + runtimeMode: parentThread.runtimeMode, + interactionMode: parentThread.interactionMode, + branch: parentThread.branch, + worktreePath: parentThread.worktreePath, + parentRelation, + createdAt: event.createdAt, + }); + } let loadedThreadDetail: OrchestrationThread | null | undefined; const getLoadedThreadDetail = () => diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index ba5eabd92c8..51e0d10f79d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -671,7 +671,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { provider: ProviderDriverKind.make("codex"), createdAt: "2026-01-01T00:00:00.000Z", method: "item/agentMessage/delta", - threadId: asThreadId("thread-1"), + threadId: asThreadId("subagent-local-child-1"), turnId: asTurnId("turn-1"), itemId: asItemId("child-msg-1"), textDelta: "Subagent ", @@ -681,6 +681,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { itemId: "child-msg-1", delta: "Subagent ", parentCollab: { + parentThreadId: "thread-1", itemId: "collab-1", detail: "Inspect routing", }, @@ -692,7 +693,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { provider: ProviderDriverKind.make("codex"), createdAt: "2026-01-01T00:00:00.000Z", method: "item/agentMessage/delta", - threadId: asThreadId("thread-1"), + threadId: asThreadId("subagent-local-child-1"), turnId: asTurnId("turn-1"), itemId: asItemId("child-msg-1"), textDelta: "result", @@ -702,6 +703,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { itemId: "child-msg-1", delta: "result", parentCollab: { + parentThreadId: "thread-1", itemId: "collab-1", detail: "Inspect routing", }, @@ -758,6 +760,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { assert.equal(completedEvent.payload.detail, "Inspect routing"); const completedData = completedEvent.payload.data as Record<string, unknown>; assert.deepEqual(completedData.parentCollab, { + parentThreadId: "thread-1", itemId: "collab-1", detail: "Inspect routing", }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index cc799d6448c..8d1acb3af11 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -95,6 +95,7 @@ interface CodexAdapterSessionContext { interface BufferedSubagentOutput { readonly parentCollab: { readonly itemId: string; + readonly parentThreadId?: string | undefined; readonly detail?: string | undefined; }; readonly content: string; @@ -516,22 +517,34 @@ function asRecord(value: unknown): Record<string, unknown> | undefined { return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined; } -function parentCollabFromPayload( - payload: ProviderEvent["payload"], -): { itemId?: string | undefined; detail?: string | undefined } | undefined { +function parentCollabFromPayload(payload: ProviderEvent["payload"]): + | { + itemId?: string | undefined; + parentThreadId?: string | undefined; + detail?: string | undefined; + } + | undefined { const parentCollab = asRecord(asRecord(payload)?.parentCollab); if (!parentCollab) { return undefined; } const itemId = typeof parentCollab.itemId === "string" ? trimText(parentCollab.itemId) : undefined; + const parentThreadId = + typeof parentCollab.parentThreadId === "string" + ? trimText(parentCollab.parentThreadId) + : undefined; const detail = typeof parentCollab.detail === "string" ? trimText(parentCollab.detail) : undefined; - return itemId || detail ? { itemId, detail } : undefined; + return itemId || parentThreadId || detail ? { itemId, parentThreadId, detail } : undefined; } function childCollabAgentMessageDelta(event: ProviderEvent): { - readonly parentCollab: { itemId?: string | undefined; detail?: string | undefined }; + readonly parentCollab: { + itemId?: string | undefined; + parentThreadId?: string | undefined; + detail?: string | undefined; + }; readonly delta: string; readonly payload: EffectCodexSchema.V2AgentMessageDeltaNotification | undefined; readonly rawPayload: Record<string, unknown> | undefined; @@ -570,11 +583,20 @@ function bufferChildCollabAgentMessageDelta( return false; } - const key = subagentOutputBufferKey(event.threadId, childDelta.parentCollab.itemId); + const parentThreadId = childDelta.parentCollab.parentThreadId + ? ThreadId.make(childDelta.parentCollab.parentThreadId) + : event.threadId; + const key = subagentOutputBufferKey(parentThreadId, childDelta.parentCollab.itemId); const previous = buffers.get(key); buffers.set(key, { parentCollab: { itemId: childDelta.parentCollab.itemId, + ...((childDelta.parentCollab.parentThreadId ?? previous?.parentCollab.parentThreadId) + ? { + parentThreadId: + childDelta.parentCollab.parentThreadId ?? previous?.parentCollab.parentThreadId, + } + : {}), ...((childDelta.parentCollab.detail ?? previous?.parentCollab.detail) ? { detail: childDelta.parentCollab.detail ?? previous?.parentCollab.detail } : {}), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index fc2b1a858b4..42b6431eab9 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -232,6 +232,7 @@ interface PendingUserInput { } interface CollabReceiverInfo { + readonly parentThreadId: ThreadId; readonly parentTurnId: TurnId | undefined; readonly parentItemId: ProviderItemId | undefined; readonly providerThreadId: string; @@ -660,6 +661,7 @@ function rememberCollabReceiverTurns( for (const receiverThreadId of notification.params.item.receiverThreadIds) { const existing = collabReceiverTurns.get(receiverThreadId); collabReceiverTurns.set(receiverThreadId, { + parentThreadId, parentTurnId: startsNewParentActivity ? parentTurnId : (existing?.parentTurnId ?? parentTurnId), @@ -673,7 +675,7 @@ function rememberCollabReceiverTurns( parentThreadId, providerThreadId: receiverThreadId, }), - rawPrompt: startsNewParentActivity ? rawPrompt : (rawPrompt ?? existing?.rawPrompt), + rawPrompt: startsNewParentActivity ? rawPrompt : (existing?.rawPrompt ?? rawPrompt), detail: startsNewParentActivity ? detail : (existing?.detail ?? detail), }); } @@ -995,11 +997,25 @@ export const makeCodexSessionRuntime = ( collabReceiverTurns, notification, ); + const parentCollab = + childParentInfo && childParentInfo.parentItemId + ? { + parentThreadId: String(childParentInfo.parentThreadId), + providerThreadId: childParentInfo.providerThreadId, + childThreadId: String(childParentInfo.childThreadId), + itemId: String(childParentInfo.parentItemId), + ...(childParentInfo.parentTurnId + ? { parentTurnId: String(childParentInfo.parentTurnId) } + : {}), + ...(childParentInfo.detail ? { detail: childParentInfo.detail } : {}), + } + : undefined; const emittedPayload = - subagentChildren.length > 0 + subagentChildren.length > 0 || parentCollab ? { ...payload, - subagentChildren, + ...(parentCollab ? { parentCollab } : {}), + ...(subagentChildren.length > 0 ? { subagentChildren } : {}), } : payload; yield* emitEvent({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 822938a1e8a..a9433f80c3e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4072,6 +4072,7 @@ function ChatViewContent(props: ChatViewProps) { const onInterrupt = async () => { if (!activeThread) return; + const activeSubagentIsRunning = activeThreadSubagentRelation?.status === "running"; if (activeThreadSubagentRelation?.status === "running") { setPendingSubagentStopThreadId(activeThread.id); } @@ -4079,6 +4080,9 @@ function ChatViewContent(props: ChatViewProps) { environmentId, input: { threadId: activeThread.id, + ...(activeSubagentIsRunning && activeThread.latestTurn?.turnId + ? { turnId: activeThread.latestTurn.turnId } + : {}), }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e4fd466f9d5..e636510e903 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -262,6 +262,50 @@ function compareSubagentSidebarChildren( return sidebarThreadKey(left).localeCompare(sidebarThreadKey(right)); } +function subagentSidebarStatus(thread: SidebarThreadSummary) { + const relation = thread.parentRelation; + return relation?.kind === "subagent" ? relation.status : null; +} + +function subagentIsTerminalInSidebar(thread: SidebarThreadSummary): boolean { + const status = subagentSidebarStatus(thread); + return status !== null && status !== "running"; +} + +function activeSidebarThreadPathKeys( + threads: readonly SidebarThreadSummary[], + activeThreadKey: string | null | undefined, +): Set<string> { + const path = new Set<string>(); + if (!activeThreadKey) { + return path; + } + const threadByKey = new Map(threads.map((thread) => [sidebarThreadKey(thread), thread] as const)); + let current = threadByKey.get(activeThreadKey) ?? null; + while (current) { + const key = sidebarThreadKey(current); + if (path.has(key)) { + break; + } + path.add(key); + const parentKey = sidebarThreadParentKey(current); + current = parentKey ? (threadByKey.get(parentKey) ?? null) : null; + } + return path; +} + +function visibleSidebarThreads( + threads: readonly SidebarThreadSummary[], + activeThreadKey: string | null | undefined, +): SidebarThreadSummary[] { + const activePathKeys = activeSidebarThreadPathKeys(threads, activeThreadKey); + return threads.filter( + (thread) => + thread.archivedAt === null && + (!subagentIsTerminalInSidebar(thread) || activePathKeys.has(sidebarThreadKey(thread))), + ); +} + function flattenSidebarThreadTree(input: { allThreads: readonly SidebarThreadSummary[]; roots: readonly SidebarThreadSummary[]; @@ -1375,7 +1419,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); }; const visibleProjectThreads = sortThreads( - projectThreads.filter((thread) => thread.archivedAt === null), + visibleSidebarThreads(projectThreads, activeRouteThreadKey), threadSortOrder, ); const visibleRootProjectThreads = rootSidebarThreads(visibleProjectThreads); @@ -1392,7 +1436,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec visibleProjectThreads, visibleRootProjectThreads, }; - }, [projectThreads, threadLastVisitedAts, threadSortOrder]); + }, [activeRouteThreadKey, projectThreads, threadLastVisitedAts, threadSortOrder]); const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -3411,8 +3455,8 @@ export default function Sidebar() { }, []); const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], + () => visibleSidebarThreads(sidebarThreads, routeThreadKey), + [routeThreadKey, sidebarThreads], ); const visibleRootThreads = useMemo(() => rootSidebarThreads(visibleThreads), [visibleThreads]); const sortedProjects = useMemo(() => { @@ -3451,9 +3495,7 @@ export default function Sidebar() { () => sortedProjects.flatMap((project) => { const projectThreads = sortThreads( - (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, - ), + visibleSidebarThreads(threadsByProjectKey.get(project.projectKey) ?? [], routeThreadKey), sidebarThreadSortOrder, ); const rootProjectThreads = rootSidebarThreads(projectThreads); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3c4391132d1..7015895671a 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1645,7 +1645,7 @@ describe("deriveWorkLogEntries", () => { ]); }); - it("drops duplicate resumed subagent child blocks within the same parent turn", () => { + it("keeps separate resumed subagent child blocks within the same parent turn", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "subagent-resume", @@ -1702,7 +1702,7 @@ describe("deriveWorkLogEntries", () => { ]; const entries = deriveWorkLogEntries(activities); - expect(entries).toHaveLength(1); + expect(entries).toHaveLength(2); expect(entries[0]?.id).toBe("subagent-resume"); expect(entries[0]?.subagentChildren).toEqual([ { @@ -1711,6 +1711,14 @@ describe("deriveWorkLogEntries", () => { titleSeed: "Say hi in German", }, ]); + expect(entries[1]?.id).toBe("subagent-send-input"); + expect(entries[1]?.subagentChildren).toEqual([ + { + threadId: "subagent-child-1", + parentItemId: "call-send-input", + titleSeed: "Say hi in German", + }, + ]); }); it("uses completed read-file output previews and still collapses the same tool call", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 9c250fa1790..00ceb3dbdea 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -815,7 +815,7 @@ function dedupeSubagentChildWorkEntries( continue; } const unseenChildren = entry.subagentChildren.filter((child) => { - const activityScope = entry.turnId ?? child.parentItemId ?? ""; + const activityScope = child.parentItemId ?? entry.turnId ?? ""; const key = `${child.threadId}:${activityScope}`; if (seenChildActivityKeys.has(key)) { return false; From f282a2bfcc5e3aab1d5fb33a55e5c4944352db2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Sat, 20 Jun 2026 14:12:59 +0100 Subject: [PATCH 09/15] Fix subagent stop and title edge cases - Mark child shells stopped when no turn can be interrupted - Generate titles for parent-linked early child shells --- .../Layers/ProviderCommandReactor.test.ts | 3 ++- .../orchestration/Layers/ProviderCommandReactor.ts | 13 ++++++++++++- .../Layers/ProviderRuntimeIngestion.test.ts | 10 +++++++++- .../Layers/ProviderRuntimeIngestion.ts | 6 ++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index bbb9b0c0862..5a8388723b7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1795,7 +1795,8 @@ describe("ProviderCommandReactor", () => { const child = readModel.threads.find((entry) => entry.id === childThreadId); expect(child?.parentRelation).toMatchObject({ kind: "subagent", - status: "running", + status: "stopped", + completedAt: now, }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 254aaf1b4ea..73ef9993a29 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -887,7 +887,7 @@ const make = Effect.gen(function* () { ? (event.payload.turnId ?? thread.latestTurn?.turnId) : undefined; if (subagentRelation && !childTurnId) { - return yield* appendProviderFailureActivity({ + yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.turn.interrupt.failed", summary: "Provider turn interrupt failed", @@ -895,6 +895,17 @@ const make = Effect.gen(function* () { turnId: null, createdAt: event.payload.createdAt, }); + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* serverCommandId("subagent-interrupt-missing-turn-status"), + threadId: event.payload.threadId, + parentRelation: { + ...subagentRelation, + completedAt: event.payload.createdAt, + status: "stopped", + }, + }); + return; } yield* providerService.interruptTurn({ threadId: providerThread.id, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index afb7b12c934..15251efcc16 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2600,7 +2600,14 @@ describe("ProviderRuntimeIngestion", () => { }); it("creates a child shell before ingesting child events with parent collab metadata", async () => { - const harness = await createHarness(); + const harness = await createHarness({ + textGeneration: { + generateThreadTitle: (input) => { + expect(input.message).toBe("Inspect early output"); + return Effect.succeed({ title: "Inspect early output" }); + }, + }, + }); const now = "2026-01-01T00:00:00.000Z"; const childThreadId = asThreadId("subagent-early-shell-test"); @@ -2633,6 +2640,7 @@ describe("ProviderRuntimeIngestion", () => { const childThread = await waitForThread( harness.readModel, (entry) => + entry.title === "Inspect early output" && entry.parentRelation?.kind === "subagent" && entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.updated", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d89e8b76ef8..40be86e69d3 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1516,6 +1516,12 @@ const make = Effect.gen(function* () { parentRelation, createdAt: event.createdAt, }); + yield* maybeGenerateSubagentThreadTitle({ + childThreadId: parentCollab.childThreadId, + titleSeed: parentRelation.titleSeed, + cwd: parentThread.worktreePath ?? process.cwd(), + createdAt: event.createdAt, + }).pipe(Effect.forkScoped); } let loadedThreadDetail: OrchestrationThread | null | undefined; From 20c2897f5b44f503b1788c9dd341b53df8319beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Sat, 20 Jun 2026 14:22:26 +0100 Subject: [PATCH 10/15] Fix subagent parent metadata ingestion - Read root-level parentCollab metadata during ingestion - Keep subagent docs and sidebar filtering aligned --- SUBAGENTS.md | 2 +- .../Layers/ProviderRuntimeIngestion.test.ts | 16 +++++++------- .../Layers/ProviderRuntimeIngestion.ts | 13 ++++++++++-- .../src/provider/Layers/CodexAdapter.ts | 14 +++++-------- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/Sidebar.tsx | 21 +++++++++++++------ 6 files changed, 40 insertions(+), 28 deletions(-) diff --git a/SUBAGENTS.md b/SUBAGENTS.md index 5894a116095..25fe4f06827 100644 --- a/SUBAGENTS.md +++ b/SUBAGENTS.md @@ -66,7 +66,7 @@ Review fixes added preservation guards so a normal root/default projection upser 6. Child terminal status is derived from child lifecycle events, not from the parent collab item alone. Terminal updates apply only while the relation is still `running`, which prevents later `session.exited` events from overwriting a more specific `completed`, `errored`, `interrupted`, or `stopped` result. -7. Child stop/interrupt handling routes through the provider-bound root session while targeting the selected child thread/turn. Parent stop remains scoped to the requested parent thread and does not cascade to active children. If a child stop request cannot identify an active child turn, the server records an interrupt failure on the child instead of falling back to the root session active turn or marking the child stopped. +7. Child stop/interrupt handling routes through the provider-bound root session while targeting the selected child thread/turn. Parent stop remains scoped to the requested parent thread and does not cascade to active children. If a child stop request cannot identify an active child turn, the server records an interrupt failure on the child, marks the child stopped, and does not fall back to the root session active turn. 8. Completed child detail remains tied to the root parent lifecycle. The orchestration decider cascades root parent archive/delete actions through active descendant subagent threads before applying the parent event, with deepest children first. Force-deleting a project delegates through lifecycle root threads so descendant subagents are deleted once through their parent root instead of being planned twice. The web action layer still performs stop, terminal-close, navigation, draft cleanup, and currently materialized descendant cleanup around those server lifecycle commands. diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 15251efcc16..96d42d605d1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2624,15 +2624,13 @@ describe("ProviderRuntimeIngestion", () => { status: "in_progress", title: "Ran command", detail: "echo child", - data: { - parentCollab: { - parentThreadId: "thread-1", - providerThreadId: "provider-child-early", - childThreadId, - parentTurnId: "turn-parent-early", - itemId: "parent-item-early", - detail: "Inspect early output", - }, + parentCollab: { + parentThreadId: "thread-1", + providerThreadId: "provider-child-early", + childThreadId, + parentTurnId: "turn-parent-early", + itemId: "parent-item-early", + detail: "Inspect early output", }, }, }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 40be86e69d3..e4a14ddb9e1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -164,8 +164,9 @@ function readRuntimeSubagentChildren( function readRuntimeSubagentParentCollab( event: ProviderRuntimeEvent, ): RuntimeSubagentParentCollab | null { - const data = asRecord(asRecord(event.payload)?.data); - const parentCollab = asRecord(data?.parentCollab); + const payload = asRecord(event.payload); + const data = asRecord(payload?.data); + const parentCollab = asRecord(payload?.parentCollab) ?? asRecord(data?.parentCollab); if (!parentCollab) { return null; } @@ -1452,6 +1453,14 @@ const make = Effect.gen(function* () { const parentThread = parentCollab ? yield* resolveThreadShell(parentCollab.parentThreadId) : null; + if (parentCollab && !parentThread) { + yield* Effect.logWarning("provider runtime ingestion could not resolve subagent parent", { + eventId: event.eventId, + eventType: event.type, + childThreadId: parentCollab.childThreadId, + parentThreadId: parentCollab.parentThreadId, + }); + } if (!parentCollab || !parentThread) { return; } diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 8d1acb3af11..6da881ed425 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -588,18 +588,14 @@ function bufferChildCollabAgentMessageDelta( : event.threadId; const key = subagentOutputBufferKey(parentThreadId, childDelta.parentCollab.itemId); const previous = buffers.get(key); + const bufferedParentThreadId = + childDelta.parentCollab.parentThreadId ?? previous?.parentCollab.parentThreadId; + const bufferedDetail = childDelta.parentCollab.detail ?? previous?.parentCollab.detail; buffers.set(key, { parentCollab: { itemId: childDelta.parentCollab.itemId, - ...((childDelta.parentCollab.parentThreadId ?? previous?.parentCollab.parentThreadId) - ? { - parentThreadId: - childDelta.parentCollab.parentThreadId ?? previous?.parentCollab.parentThreadId, - } - : {}), - ...((childDelta.parentCollab.detail ?? previous?.parentCollab.detail) - ? { detail: childDelta.parentCollab.detail ?? previous?.parentCollab.detail } - : {}), + parentThreadId: bufferedParentThreadId, + detail: bufferedDetail, }, content: `${previous?.content ?? ""}${childDelta.delta}`, }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a9433f80c3e..12d9ed734f4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4073,7 +4073,7 @@ function ChatViewContent(props: ChatViewProps) { const onInterrupt = async () => { if (!activeThread) return; const activeSubagentIsRunning = activeThreadSubagentRelation?.status === "running"; - if (activeThreadSubagentRelation?.status === "running") { + if (activeSubagentIsRunning) { setPendingSubagentStopThreadId(activeThread.id); } const result = await interruptThreadTurn({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e636510e903..78a54f6dda7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -296,9 +296,8 @@ function activeSidebarThreadPathKeys( function visibleSidebarThreads( threads: readonly SidebarThreadSummary[], - activeThreadKey: string | null | undefined, + activePathKeys: ReadonlySet<string>, ): SidebarThreadSummary[] { - const activePathKeys = activeSidebarThreadPathKeys(threads, activeThreadKey); return threads.filter( (thread) => thread.archivedAt === null && @@ -1418,8 +1417,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; + const activeProjectThreadPathKeys = activeSidebarThreadPathKeys( + projectThreads, + activeRouteThreadKey, + ); const visibleProjectThreads = sortThreads( - visibleSidebarThreads(projectThreads, activeRouteThreadKey), + visibleSidebarThreads(projectThreads, activeProjectThreadPathKeys), threadSortOrder, ); const visibleRootProjectThreads = rootSidebarThreads(visibleProjectThreads); @@ -3454,10 +3457,14 @@ export default function Sidebar() { animatedThreadListsRef.current.add(node); }, []); - const visibleThreads = useMemo( - () => visibleSidebarThreads(sidebarThreads, routeThreadKey), + const activeSidebarPathKeys = useMemo( + () => activeSidebarThreadPathKeys(sidebarThreads, routeThreadKey), [routeThreadKey, sidebarThreads], ); + const visibleThreads = useMemo( + () => visibleSidebarThreads(sidebarThreads, activeSidebarPathKeys), + [activeSidebarPathKeys, sidebarThreads], + ); const visibleRootThreads = useMemo(() => rootSidebarThreads(visibleThreads), [visibleThreads]); const sortedProjects = useMemo(() => { const sortableProjects = sidebarProjects.map((project) => ({ @@ -3494,8 +3501,9 @@ export default function Sidebar() { const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { + const allProjectThreads = threadsByProjectKey.get(project.projectKey) ?? []; const projectThreads = sortThreads( - visibleSidebarThreads(threadsByProjectKey.get(project.projectKey) ?? [], routeThreadKey), + visibleSidebarThreads(allProjectThreads, activeSidebarPathKeys), sidebarThreadSortOrder, ); const rootProjectThreads = rootSidebarThreads(projectThreads); @@ -3540,6 +3548,7 @@ export default function Sidebar() { sidebarThreadPreviewCount, expandedThreadListsByProject, projectExpandedById, + activeSidebarPathKeys, routeThreadKey, sortedProjects, threadsByProjectKey, From 85169596e3abca87549efa5e40dcd55552370926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Sat, 20 Jun 2026 15:19:49 +0100 Subject: [PATCH 11/15] Add sidebar subagent nesting and status matching - Keep terminal sidebar threads visible when a descendant is active - Match resumed subagent timeline status by parent item first --- apps/web/src/components/Sidebar.logic.test.ts | 99 +++++++ apps/web/src/components/Sidebar.logic.ts | 163 +++++++++++ apps/web/src/components/Sidebar.tsx | 271 ++++++------------ .../components/chat/MessagesTimeline.test.tsx | 71 ++++- .../src/components/chat/MessagesTimeline.tsx | 9 +- 5 files changed, 421 insertions(+), 192 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..d5567713b88 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + activeSidebarThreadPathKeys, createThreadJumpHintVisibilityController, + flattenSidebarThreadTree, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -15,11 +17,14 @@ import { resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, + rootSidebarThreads, resolveThreadRowClassName, resolveThreadStatusPill, + sidebarThreadKey, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, + visibleSidebarThreads, } from "./Sidebar.logic"; import { EnvironmentId, @@ -32,6 +37,7 @@ import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, + type SidebarThreadSummary, type Thread, } from "../types"; @@ -89,6 +95,99 @@ function makeLatestTurn(overrides?: { }; } +function makeSidebarThread(input: { + id: string; + parentThreadId?: string; + parentActivitySequence?: number; + status?: "running" | "completed" | "errored" | "interrupted" | "stopped"; +}): SidebarThreadSummary { + return { + id: ThreadId.make(input.id), + environmentId: localEnvironmentId, + projectId: ProjectId.make("project-1"), + title: input.id, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + archivedAt: null, + parentRelation: input.parentThreadId + ? { + kind: "subagent", + rootThreadId: ThreadId.make("root-thread"), + parentThreadId: ThreadId.make(input.parentThreadId), + parentTurnId: null, + parentItemId: null, + parentActivitySequence: input.parentActivitySequence ?? 1, + providerThreadId: `provider-${input.id}`, + titleSeed: input.id, + depth: 1, + startedAt: "2026-03-09T10:00:00.000Z", + completedAt: input.status === "running" ? null : "2026-03-09T10:01:00.000Z", + status: input.status ?? "running", + } + : { + kind: "root", + rootThreadId: ThreadId.make(input.id), + }, + } as SidebarThreadSummary; +} + +describe("subagent sidebar tree helpers", () => { + it("keeps running descendants nested under hidden terminal ancestors", () => { + const root = makeSidebarThread({ id: "root-thread" }); + const terminalChild = makeSidebarThread({ + id: "terminal-child", + parentThreadId: "root-thread", + parentActivitySequence: 1, + status: "completed", + }); + const runningGrandchild = makeSidebarThread({ + id: "running-grandchild", + parentThreadId: "terminal-child", + parentActivitySequence: 1, + status: "running", + }); + const allThreads = [root, terminalChild, runningGrandchild]; + const visibleThreads = visibleSidebarThreads(allThreads, new Set()); + + expect(visibleThreads.map((thread) => thread.id)).toEqual([ + ThreadId.make("root-thread"), + ThreadId.make("running-grandchild"), + ]); + expect(rootSidebarThreads(visibleThreads, allThreads).map((thread) => thread.id)).toEqual([ + ThreadId.make("root-thread"), + ]); + + const rendered = flattenSidebarThreadTree({ + allThreads, + roots: [root], + visibleThreadKeys: new Set(visibleThreads.map(sidebarThreadKey)), + }); + + expect(rendered.map(({ thread, depth }) => [thread.id, depth])).toEqual([ + [ThreadId.make("root-thread"), 0], + [ThreadId.make("running-grandchild"), 2], + ]); + }); + + it("keeps an active terminal subagent visible through its parent path", () => { + const root = makeSidebarThread({ id: "root-thread" }); + const terminalChild = makeSidebarThread({ + id: "terminal-child", + parentThreadId: "root-thread", + status: "completed", + }); + const allThreads = [root, terminalChild]; + const activePathKeys = activeSidebarThreadPathKeys(allThreads, sidebarThreadKey(terminalChild)); + const visibleThreads = visibleSidebarThreads(allThreads, activePathKeys); + + expect(visibleThreads.map((thread) => thread.id)).toEqual([ + ThreadId.make("root-thread"), + ThreadId.make("terminal-child"), + ]); + expect([...activePathKeys]).toEqual([sidebarThreadKey(terminalChild), sidebarThreadKey(root)]); + }); +}); + describe("hasUnseenCompletion", () => { it("returns true when a thread completed after its last visit", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5c70d447d2b..cb8902ab64d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -9,6 +9,7 @@ import { import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; @@ -17,6 +18,10 @@ const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; export type SidebarNewThreadEnvMode = "local" | "worktree"; +export interface RenderedSidebarThread { + thread: SidebarThreadSummary; + depth: number; +} type SidebarProject = { id: string; title: string; @@ -166,6 +171,164 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { return completedAt > lastVisitedAt; } +export function sidebarThreadKey( + thread: Pick<SidebarThreadSummary, "environmentId" | "id">, +): string { + return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); +} + +export function sidebarThreadParentKey(thread: SidebarThreadSummary): string | null { + const relation = thread.parentRelation; + if (relation?.kind !== "subagent") { + return null; + } + return scopedThreadKey(scopeThreadRef(thread.environmentId, relation.parentThreadId)); +} + +function compareSubagentSidebarChildren( + left: SidebarThreadSummary, + right: SidebarThreadSummary, +): number { + const leftRelation = left.parentRelation?.kind === "subagent" ? left.parentRelation : null; + const rightRelation = right.parentRelation?.kind === "subagent" ? right.parentRelation : null; + const sequence = + (leftRelation?.parentActivitySequence ?? 0) - (rightRelation?.parentActivitySequence ?? 0); + if (sequence !== 0) { + return sequence; + } + const startedAt = (leftRelation?.startedAt ?? left.createdAt).localeCompare( + rightRelation?.startedAt ?? right.createdAt, + ); + if (startedAt !== 0) { + return startedAt; + } + return sidebarThreadKey(left).localeCompare(sidebarThreadKey(right)); +} + +function subagentSidebarStatus(thread: SidebarThreadSummary) { + const relation = thread.parentRelation; + return relation?.kind === "subagent" ? relation.status : null; +} + +function subagentIsTerminalInSidebar(thread: SidebarThreadSummary): boolean { + const status = subagentSidebarStatus(thread); + return status !== null && status !== "running"; +} + +export function activeSidebarThreadPathKeys( + threads: readonly SidebarThreadSummary[], + activeThreadKey: string | null | undefined, +): Set<string> { + const path = new Set<string>(); + if (!activeThreadKey) { + return path; + } + const threadByKey = new Map(threads.map((thread) => [sidebarThreadKey(thread), thread] as const)); + let current = threadByKey.get(activeThreadKey) ?? null; + while (current) { + const key = sidebarThreadKey(current); + if (path.has(key)) { + break; + } + path.add(key); + const parentKey = sidebarThreadParentKey(current); + current = parentKey ? (threadByKey.get(parentKey) ?? null) : null; + } + return path; +} + +export function visibleSidebarThreads( + threads: readonly SidebarThreadSummary[], + activePathKeys: ReadonlySet<string>, +): SidebarThreadSummary[] { + return threads.filter( + (thread) => + thread.archivedAt === null && + (!subagentIsTerminalInSidebar(thread) || activePathKeys.has(sidebarThreadKey(thread))), + ); +} + +export function flattenSidebarThreadTree(input: { + allThreads: readonly SidebarThreadSummary[]; + roots: readonly SidebarThreadSummary[]; + visibleThreadKeys?: ReadonlySet<string>; +}): RenderedSidebarThread[] { + const allThreadKeys = new Set(input.allThreads.map(sidebarThreadKey)); + const childrenByParentKey = new Map<string, SidebarThreadSummary[]>(); + for (const thread of input.allThreads) { + const parentKey = sidebarThreadParentKey(thread); + if (!parentKey || !allThreadKeys.has(parentKey)) { + continue; + } + const children = childrenByParentKey.get(parentKey); + if (children) { + children.push(thread); + } else { + childrenByParentKey.set(parentKey, [thread]); + } + } + for (const children of childrenByParentKey.values()) { + children.sort(compareSubagentSidebarChildren); + } + + const result: RenderedSidebarThread[] = []; + const visited = new Set<string>(); + const visit = (thread: SidebarThreadSummary, depth: number) => { + const key = sidebarThreadKey(thread); + if (visited.has(key)) { + return; + } + visited.add(key); + if (!input.visibleThreadKeys || input.visibleThreadKeys.has(key)) { + result.push({ thread, depth }); + } + for (const child of childrenByParentKey.get(key) ?? []) { + visit(child, depth + 1); + } + }; + for (const root of input.roots) { + visit(root, 0); + } + return result; +} + +export function resolveSidebarRootThread( + threads: readonly SidebarThreadSummary[], + threadKey: string, + threadByKey = new Map(threads.map((thread) => [sidebarThreadKey(thread), thread] as const)), +): SidebarThreadSummary | null { + let current = threadByKey.get(threadKey) ?? null; + const seen = new Set<string>(); + while (current) { + const currentKey = sidebarThreadKey(current); + if (seen.has(currentKey)) { + return current; + } + seen.add(currentKey); + const parentKey = sidebarThreadParentKey(current); + if (!parentKey) { + return current; + } + const parent = threadByKey.get(parentKey); + if (!parent) { + return current; + } + current = parent; + } + return null; +} + +export function rootSidebarThreads( + threads: readonly SidebarThreadSummary[], + allThreads: readonly SidebarThreadSummary[] = threads, +): SidebarThreadSummary[] { + const keys = new Set(allThreads.map(sidebarThreadKey)); + return threads.filter((thread) => { + const parentKey = sidebarThreadParentKey(thread); + return !parentKey || !keys.has(parentKey); + }); +} + export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { if (target === null) return true; return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 78a54f6dda7..f3db3d67358 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -75,6 +75,7 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { readThreadShell, + useThreadShell, useProject, useProjects, useThreadShells, @@ -178,20 +179,27 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { + activeSidebarThreadPathKeys, + flattenSidebarThreadTree, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, resolveProjectStatusIndicator, + resolveSidebarRootThread, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, + rootSidebarThreads, + sidebarThreadKey, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, resolveSidebarStageBadgeLabel, useThreadJumpHintVisibility, + visibleSidebarThreads, + type RenderedSidebarThread, ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; @@ -225,166 +233,23 @@ const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = { created_at: "Created at", }; -interface RenderedSidebarThread { - thread: SidebarThreadSummary; - depth: number; -} - -function sidebarThreadKey(thread: Pick<SidebarThreadSummary, "environmentId" | "id">): string { - return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); -} - -function sidebarThreadParentKey(thread: SidebarThreadSummary): string | null { - const relation = thread.parentRelation; - if (relation?.kind !== "subagent") { - return null; - } - return scopedThreadKey(scopeThreadRef(thread.environmentId, relation.parentThreadId)); -} - -function compareSubagentSidebarChildren( - left: SidebarThreadSummary, - right: SidebarThreadSummary, -): number { - const leftRelation = left.parentRelation?.kind === "subagent" ? left.parentRelation : null; - const rightRelation = right.parentRelation?.kind === "subagent" ? right.parentRelation : null; - const sequence = - (leftRelation?.parentActivitySequence ?? 0) - (rightRelation?.parentActivitySequence ?? 0); - if (sequence !== 0) { - return sequence; - } - const startedAt = (leftRelation?.startedAt ?? left.createdAt).localeCompare( - rightRelation?.startedAt ?? right.createdAt, - ); - if (startedAt !== 0) { - return startedAt; - } - return sidebarThreadKey(left).localeCompare(sidebarThreadKey(right)); -} - -function subagentSidebarStatus(thread: SidebarThreadSummary) { - const relation = thread.parentRelation; - return relation?.kind === "subagent" ? relation.status : null; -} - -function subagentIsTerminalInSidebar(thread: SidebarThreadSummary): boolean { - const status = subagentSidebarStatus(thread); - return status !== null && status !== "running"; -} - -function activeSidebarThreadPathKeys( - threads: readonly SidebarThreadSummary[], - activeThreadKey: string | null | undefined, -): Set<string> { - const path = new Set<string>(); - if (!activeThreadKey) { - return path; - } - const threadByKey = new Map(threads.map((thread) => [sidebarThreadKey(thread), thread] as const)); - let current = threadByKey.get(activeThreadKey) ?? null; - while (current) { - const key = sidebarThreadKey(current); - if (path.has(key)) { - break; - } - path.add(key); - const parentKey = sidebarThreadParentKey(current); - current = parentKey ? (threadByKey.get(parentKey) ?? null) : null; - } - return path; -} - -function visibleSidebarThreads( - threads: readonly SidebarThreadSummary[], - activePathKeys: ReadonlySet<string>, -): SidebarThreadSummary[] { - return threads.filter( - (thread) => - thread.archivedAt === null && - (!subagentIsTerminalInSidebar(thread) || activePathKeys.has(sidebarThreadKey(thread))), - ); -} - -function flattenSidebarThreadTree(input: { - allThreads: readonly SidebarThreadSummary[]; - roots: readonly SidebarThreadSummary[]; -}): RenderedSidebarThread[] { - const allThreadKeys = new Set(input.allThreads.map(sidebarThreadKey)); - const childrenByParentKey = new Map<string, SidebarThreadSummary[]>(); - for (const thread of input.allThreads) { - const parentKey = sidebarThreadParentKey(thread); - if (!parentKey || !allThreadKeys.has(parentKey)) { - continue; - } - const children = childrenByParentKey.get(parentKey); - if (children) { - children.push(thread); - } else { - childrenByParentKey.set(parentKey, [thread]); - } - } - for (const children of childrenByParentKey.values()) { - children.sort(compareSubagentSidebarChildren); - } - - const result: RenderedSidebarThread[] = []; - const visited = new Set<string>(); - const visit = (thread: SidebarThreadSummary, depth: number) => { - const key = sidebarThreadKey(thread); - if (visited.has(key)) { - return; - } - visited.add(key); - result.push({ thread, depth }); - for (const child of childrenByParentKey.get(key) ?? []) { - visit(child, depth + 1); - } - }; - for (const root of input.roots) { - visit(root, 0); - } - return result; -} - -function resolveSidebarRootThread( - threads: readonly SidebarThreadSummary[], - threadKey: string, - threadByKey = new Map(threads.map((thread) => [sidebarThreadKey(thread), thread] as const)), -): SidebarThreadSummary | null { - let current = threadByKey.get(threadKey) ?? null; - const seen = new Set<string>(); - while (current) { - const currentKey = sidebarThreadKey(current); - if (seen.has(currentKey)) { - return current; - } - seen.add(currentKey); - const parentKey = sidebarThreadParentKey(current); - if (!parentKey) { - return current; - } - const parent = threadByKey.get(parentKey); - if (!parent) { - return current; - } - current = parent; - } - return null; -} - -function rootSidebarThreads(threads: readonly SidebarThreadSummary[]): SidebarThreadSummary[] { - const keys = new Set(threads.map(sidebarThreadKey)); - return threads.filter((thread) => { - const parentKey = sidebarThreadParentKey(thread); - return !parentKey || !keys.has(parentKey); - }); -} - const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>(); +function includeSidebarThreadByKey( + threads: readonly SidebarThreadSummary[], + thread: SidebarThreadSummary | null | undefined, +): readonly SidebarThreadSummary[] { + if (!thread) { + return threads; + } + const key = sidebarThreadKey(thread); + return threads.some((candidate) => sidebarThreadKey(candidate) === key) + ? threads + : [...threads, thread]; +} const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { repository: "Group by repository", repository_path: "Group by repository path", @@ -1212,6 +1077,7 @@ interface SidebarProjectItemProps { project: SidebarProjectSnapshot; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; + activeRouteThread: SidebarThreadSummary | null; newThreadShortcutLabel: string | null; handleNewThread: ReturnType<typeof useNewThreadHandler>; archiveThread: ReturnType<typeof useThreadActions>["archiveThread"]; @@ -1232,6 +1098,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec project, isThreadListExpanded, activeRouteThreadKey, + activeRouteThread, newThreadShortcutLabel, handleNewThread, archiveThread, @@ -1323,22 +1190,25 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); const openPrLink = useOpenPrLink(); const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs); + const projectThreads = useMemo( + () => includeSidebarThreadByKey(sidebarThreads, activeRouteThread), + [activeRouteThread, sidebarThreads], + ); const sidebarThreadByKey = useMemo( () => new Map( - sidebarThreads.map( + projectThreads.map( (thread) => [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, ), ), - [sidebarThreads], + [projectThreads], ); // Keep a ref so callbacks can read the latest map without appearing in // dependency arrays (avoids invalidating every thread-row memo on each // thread-list change). const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); sidebarThreadByKeyRef.current = sidebarThreadByKey; - const projectThreads = sidebarThreads; const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]); const projectExpanded = useUiStateStore((state) => resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys), @@ -1396,6 +1266,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const { projectStatus, + sortedProjectThreads, visibleProjectThreads, visibleRootProjectThreads, orderedProjectThreadKeys, @@ -1421,14 +1292,23 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectThreads, activeRouteThreadKey, ); - const visibleProjectThreads = sortThreads( - visibleSidebarThreads(projectThreads, activeProjectThreadPathKeys), + const sortedProjectThreads = sortThreads( + projectThreads.filter((thread) => thread.archivedAt === null), threadSortOrder, ); - const visibleRootProjectThreads = rootSidebarThreads(visibleProjectThreads); + const visibleProjectThreads = visibleSidebarThreads( + sortedProjectThreads, + activeProjectThreadPathKeys, + ); + const visibleProjectThreadKeys = new Set(visibleProjectThreads.map(sidebarThreadKey)); + const visibleRootProjectThreads = rootSidebarThreads( + visibleProjectThreads, + sortedProjectThreads, + ); const orderedProjectThreadKeys = flattenSidebarThreadTree({ - allThreads: visibleProjectThreads, + allThreads: sortedProjectThreads, roots: visibleRootProjectThreads, + visibleThreadKeys: visibleProjectThreadKeys, }).map(({ thread }) => sidebarThreadKey(thread)); const projectStatus = resolveProjectStatusIndicator( visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)), @@ -1436,6 +1316,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return { orderedProjectThreadKeys, projectStatus, + sortedProjectThreads, visibleProjectThreads, visibleRootProjectThreads, }; @@ -1478,7 +1359,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? visibleRootProjectThreads : visibleRootProjectThreads.slice(0, sidebarThreadPreviewCount); const activeRouteRootThread = activeRouteThreadKey - ? resolveSidebarRootThread(visibleProjectThreads, activeRouteThreadKey) + ? resolveSidebarRootThread(sortedProjectThreads, activeRouteThreadKey) : null; const visibleThreadKeys = new Set( [ @@ -1493,25 +1374,19 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec visibleThreadKeys.has(sidebarThreadKey(thread)), ); const renderedThreads = flattenSidebarThreadTree({ - allThreads: visibleProjectThreads, + allThreads: sortedProjectThreads, roots: renderedRoots, + visibleThreadKeys: new Set(visibleProjectThreads.map(sidebarThreadKey)), }); const hiddenRootKeys = new Set( visibleRootProjectThreads .filter((thread) => !visibleThreadKeys.has(sidebarThreadKey(thread))) .map(sidebarThreadKey), ); - const visibleThreadByKey = new Map( - visibleProjectThreads.map((thread) => [sidebarThreadKey(thread), thread] as const), - ); const hiddenThreads = visibleProjectThreads.filter((thread) => hiddenRootKeys.has( sidebarThreadKey( - resolveSidebarRootThread( - visibleProjectThreads, - sidebarThreadKey(thread), - visibleThreadByKey, - ) ?? thread, + resolveSidebarRootThread(sortedProjectThreads, sidebarThreadKey(thread)) ?? thread, ), ), ); @@ -1529,8 +1404,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec isThreadListExpanded, pinnedCollapsedThread, projectExpanded, - projectThreads, sidebarThreadPreviewCount, + sortedProjectThreads, threadLastVisitedAts, visibleProjectThreads, visibleRootProjectThreads, @@ -2965,6 +2840,7 @@ interface SidebarProjectsContentProps { expandedThreadListsByProject: ReadonlySet<string>; activeRouteProjectKey: string | null; routeThreadKey: string | null; + routeThread: SidebarThreadSummary | null; newThreadShortcutLabel: string | null; commandPaletteShortcutLabel: string | null; threadJumpLabelByKey: ReadonlyMap<string, string>; @@ -3006,6 +2882,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( expandedThreadListsByProject, activeRouteProjectKey, routeThreadKey, + routeThread, newThreadShortcutLabel, commandPaletteShortcutLabel, threadJumpLabelByKey, @@ -3150,6 +3027,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null } + activeRouteThread={ + activeRouteProjectKey === project.projectKey ? routeThread : null + } newThreadShortcutLabel={newThreadShortcutLabel} handleNewThread={handleNewThread} archiveThread={archiveThread} @@ -3182,6 +3062,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null } + activeRouteThread={ + activeRouteProjectKey === project.projectKey ? routeThread : null + } newThreadShortcutLabel={newThreadShortcutLabel} handleNewThread={handleNewThread} archiveThread={archiveThread} @@ -3233,6 +3116,11 @@ export default function Sidebar() { select: (params) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const routeThread = useThreadShell(routeThreadRef); + const sidebarThreadsWithRoute = useMemo( + () => includeSidebarThreadByKey(sidebarThreads, routeThread), + [routeThread, sidebarThreads], + ); const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -3309,12 +3197,12 @@ export default function Sidebar() { const sidebarThreadByKey = useMemo( () => new Map( - sidebarThreads.map( + sidebarThreadsWithRoute.map( (thread) => [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, ), ), - [sidebarThreads], + [sidebarThreadsWithRoute], ); // Resolve the active route's project key to a logical key so it matches the // sidebar's grouped project entries. @@ -3335,7 +3223,7 @@ export default function Sidebar() { // are displayed together. const threadsByProjectKey = useMemo(() => { const next = new Map<string, SidebarThreadSummary[]>(); - for (const thread of sidebarThreads) { + for (const thread of sidebarThreadsWithRoute) { const physicalKey = projectPhysicalKeyByScopedRef.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), @@ -3349,7 +3237,7 @@ export default function Sidebar() { } } return next; - }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + }, [sidebarThreadsWithRoute, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -3458,14 +3346,21 @@ export default function Sidebar() { }, []); const activeSidebarPathKeys = useMemo( - () => activeSidebarThreadPathKeys(sidebarThreads, routeThreadKey), - [routeThreadKey, sidebarThreads], + () => activeSidebarThreadPathKeys(sidebarThreadsWithRoute, routeThreadKey), + [routeThreadKey, sidebarThreadsWithRoute], ); const visibleThreads = useMemo( - () => visibleSidebarThreads(sidebarThreads, activeSidebarPathKeys), - [activeSidebarPathKeys, sidebarThreads], + () => visibleSidebarThreads(sidebarThreadsWithRoute, activeSidebarPathKeys), + [activeSidebarPathKeys, sidebarThreadsWithRoute], + ); + const sidebarStructuralThreads = useMemo( + () => sidebarThreadsWithRoute.filter((thread) => thread.archivedAt === null), + [sidebarThreadsWithRoute], + ); + const visibleRootThreads = useMemo( + () => rootSidebarThreads(visibleThreads, sidebarStructuralThreads), + [sidebarStructuralThreads, visibleThreads], ); - const visibleRootThreads = useMemo(() => rootSidebarThreads(visibleThreads), [visibleThreads]); const sortedProjects = useMemo(() => { const sortableProjects = sidebarProjects.map((project) => ({ ...project, @@ -3502,11 +3397,13 @@ export default function Sidebar() { () => sortedProjects.flatMap((project) => { const allProjectThreads = threadsByProjectKey.get(project.projectKey) ?? []; - const projectThreads = sortThreads( - visibleSidebarThreads(allProjectThreads, activeSidebarPathKeys), + const sortedProjectThreads = sortThreads( + allProjectThreads.filter((thread) => thread.archivedAt === null), sidebarThreadSortOrder, ); - const rootProjectThreads = rootSidebarThreads(projectThreads); + const projectThreads = visibleSidebarThreads(sortedProjectThreads, activeSidebarPathKeys); + const visibleProjectThreadKeys = new Set(projectThreads.map(sidebarThreadKey)); + const rootProjectThreads = rootSidebarThreads(projectThreads, sortedProjectThreads); const projectExpanded = resolveProjectExpanded( projectExpandedById, projectExpansionPreferenceKeys(project), @@ -3539,8 +3436,9 @@ export default function Sidebar() { ? [pinnedCollapsedThread, ...(activeRouteRootThread ? [activeRouteRootThread] : [])] : rootProjectThreads.filter((thread) => renderedRootKeys.has(sidebarThreadKey(thread))); return flattenSidebarThreadTree({ - allThreads: projectThreads, + allThreads: sortedProjectThreads, roots: renderedRoots, + visibleThreadKeys: visibleProjectThreadKeys, }).map(({ thread }) => sidebarThreadKey(thread)); }), [ @@ -3835,6 +3733,7 @@ export default function Sidebar() { expandedThreadListsByProject={expandedThreadListsByProject} activeRouteProjectKey={activeRouteProjectKey} routeThreadKey={routeThreadKey} + routeThread={routeThread} newThreadShortcutLabel={newThreadShortcutLabel} commandPaletteShortcutLabel={commandPaletteShortcutLabel} threadJumpLabelByKey={visibleThreadJumpLabelByKey} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index ce80ddcfc57..57458e1a297 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -327,7 +327,7 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("</review_comment>"); }); - it("renders a deduped resumed subagent block as working when the parent turn matches", async () => { + it("renders a deduped resumed subagent block as working when the parent item matches", async () => { const childThreadId = ThreadId.make("subagent-child-1"); const parentTurnId = TurnId.make("turn-followup"); storeMock.state = { @@ -379,7 +379,7 @@ describe("MessagesTimeline", () => { subagentChildren: [ { threadId: childThreadId, - parentItemId: "call-resume", + parentItemId: "call-send-input", titleSeed: "Say hi in German", }, ], @@ -394,6 +394,73 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("Completed in"); }); + it("does not reuse running subagent status for a different same-turn parent item", async () => { + const childThreadId = ThreadId.make("subagent-child-1"); + const parentTurnId = TurnId.make("turn-followup"); + storeMock.state = { + threadShellByKey: { + [`${ACTIVE_THREAD_ENVIRONMENT_ID}\0${childThreadId}`]: { + id: childThreadId, + title: "Say hi briefly", + parentRelation: { + kind: "subagent", + rootThreadId: ThreadId.make("thread-1"), + parentThreadId: ThreadId.make("thread-1"), + parentTurnId, + parentItemId: "call-send-input", + parentActivitySequence: 2, + providerThreadId: "provider-child-1", + titleSeed: "Say hi in German", + depth: 1, + startedAt: "2026-03-17T19:12:35.000Z", + completedAt: null, + status: "running", + }, + }, + }, + }; + + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + <MessagesTimeline + {...buildProps()} + activeTurnInProgress={true} + latestTurn={{ + turnId: parentTurnId, + state: "running", + startedAt: "2026-03-17T19:12:30.000Z", + completedAt: null, + }} + timelineEntries={[ + { + id: "entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:30.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:30.000Z", + turnId: parentTurnId, + label: "Subagent", + tone: "tool", + itemType: "collab_agent_tool_call", + subagentChildren: [ + { + threadId: childThreadId, + parentItemId: "call-resume", + titleSeed: "Say hi in German", + }, + ], + }, + }, + ]} + />, + ); + + expect(markup).toContain("Subagent - Say hi briefly"); + expect(markup).not.toContain("Working"); + expect(markup).toContain("duration unknown"); + }); + it("renders file review comments as source code instead of diffs", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2cce9dde866..81f2c49000c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1814,10 +1814,11 @@ const SubagentWorkEntryButton = memo(function SubagentWorkEntryButton(props: { const relationParentItemId = relation?.parentItemId ?? null; const relationParentTurnId = relation?.parentTurnId ?? null; const relationMatchesThisBlock = - Boolean(props.parentTurnId && props.parentTurnId === relationParentTurnId) || - !props.parentItemId || - !relationParentItemId || - relationParentItemId === props.parentItemId; + props.parentItemId || relationParentItemId + ? props.parentItemId === relationParentItemId + : props.parentTurnId || relationParentTurnId + ? props.parentTurnId === relationParentTurnId + : true; if (relation && relationMatchesThisBlock && relation.status !== "running") { terminalSnapshotRef.current = { status: relation.status, From 2ef7d4db014bfdfb619de98b6ec0d6a4ee8f2994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Sat, 20 Jun 2026 15:36:16 +0100 Subject: [PATCH 12/15] Fix subagent status turn fallback - Match by parent turn when child item ids are incomplete - Cover missing child parent item ids in timeline rendering --- .../components/chat/MessagesTimeline.test.tsx | 66 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 2 +- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 57458e1a297..3657f2e6b8f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -394,6 +394,72 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("Completed in"); }); + it("falls back to parent turn matching when the child item id is absent", async () => { + const childThreadId = ThreadId.make("subagent-child-1"); + const parentTurnId = TurnId.make("turn-followup"); + storeMock.state = { + threadShellByKey: { + [`${ACTIVE_THREAD_ENVIRONMENT_ID}\0${childThreadId}`]: { + id: childThreadId, + title: "Say hi briefly", + parentRelation: { + kind: "subagent", + rootThreadId: ThreadId.make("thread-1"), + parentThreadId: ThreadId.make("thread-1"), + parentTurnId, + parentItemId: "call-send-input", + parentActivitySequence: 2, + providerThreadId: "provider-child-1", + titleSeed: "Say hi in German", + depth: 1, + startedAt: "2026-03-17T19:12:35.000Z", + completedAt: null, + status: "running", + }, + }, + }, + }; + + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + <MessagesTimeline + {...buildProps()} + activeTurnInProgress={true} + latestTurn={{ + turnId: parentTurnId, + state: "running", + startedAt: "2026-03-17T19:12:30.000Z", + completedAt: null, + }} + timelineEntries={[ + { + id: "entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:30.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:30.000Z", + turnId: parentTurnId, + label: "Subagent", + tone: "tool", + itemType: "collab_agent_tool_call", + subagentChildren: [ + { + threadId: childThreadId, + titleSeed: "Say hi in German", + }, + ], + }, + }, + ]} + />, + ); + + expect(markup).toContain("Subagent - Say hi briefly"); + expect(markup).toContain("Working"); + expect(markup).not.toContain("Completed in"); + }); + it("does not reuse running subagent status for a different same-turn parent item", async () => { const childThreadId = ThreadId.make("subagent-child-1"); const parentTurnId = TurnId.make("turn-followup"); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 81f2c49000c..faa0523b6df 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1814,7 +1814,7 @@ const SubagentWorkEntryButton = memo(function SubagentWorkEntryButton(props: { const relationParentItemId = relation?.parentItemId ?? null; const relationParentTurnId = relation?.parentTurnId ?? null; const relationMatchesThisBlock = - props.parentItemId || relationParentItemId + props.parentItemId && relationParentItemId ? props.parentItemId === relationParentItemId : props.parentTurnId || relationParentTurnId ? props.parentTurnId === relationParentTurnId From 92b659630b81711e8a0b2f709183719f68cc0c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Sat, 20 Jun 2026 15:49:06 +0100 Subject: [PATCH 13/15] Fix subagent thread matching and sidebar maps - Require turn matches for reused subagent parent item ids - Reuse sidebar thread maps and make root callers explicit - Add regression coverage for subagent ordering and matching --- apps/web/src/components/Sidebar.logic.test.ts | 72 +++++++++++++- apps/web/src/components/Sidebar.logic.ts | 2 +- apps/web/src/components/Sidebar.tsx | 78 ++++++++++------ .../components/chat/MessagesTimeline.test.tsx | 93 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 35 +++++-- 5 files changed, 241 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d5567713b88..29c788c5dad 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -30,6 +30,7 @@ import { EnvironmentId, OrchestrationLatestTurn, ProjectId, + ProviderItemId, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; @@ -97,8 +98,10 @@ function makeLatestTurn(overrides?: { function makeSidebarThread(input: { id: string; + createdAt?: string; parentThreadId?: string; parentActivitySequence?: number; + startedAt?: string; status?: "running" | "completed" | "errored" | "interrupted" | "stopped"; }): SidebarThreadSummary { return { @@ -106,7 +109,16 @@ function makeSidebarThread(input: { environmentId: localEnvironmentId, projectId: ProjectId.make("project-1"), title: input.id, - createdAt: "2026-03-09T10:00:00.000Z", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: input.createdAt ?? "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", archivedAt: null, parentRelation: input.parentThreadId @@ -115,12 +127,12 @@ function makeSidebarThread(input: { rootThreadId: ThreadId.make("root-thread"), parentThreadId: ThreadId.make(input.parentThreadId), parentTurnId: null, - parentItemId: null, + parentItemId: ProviderItemId.make(`item-${input.id}`), parentActivitySequence: input.parentActivitySequence ?? 1, providerThreadId: `provider-${input.id}`, titleSeed: input.id, depth: 1, - startedAt: "2026-03-09T10:00:00.000Z", + startedAt: input.startedAt ?? "2026-03-09T10:00:00.000Z", completedAt: input.status === "running" ? null : "2026-03-09T10:01:00.000Z", status: input.status ?? "running", } @@ -128,7 +140,12 @@ function makeSidebarThread(input: { kind: "root", rootThreadId: ThreadId.make(input.id), }, - } as SidebarThreadSummary; + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; } describe("subagent sidebar tree helpers", () => { @@ -186,6 +203,53 @@ describe("subagent sidebar tree helpers", () => { ]); expect([...activePathKeys]).toEqual([sidebarThreadKey(terminalChild), sidebarThreadKey(root)]); }); + + it("orders sibling subagents by parent sequence, start time, and thread key", () => { + const root = makeSidebarThread({ id: "root-thread" }); + const laterSequence = makeSidebarThread({ + id: "child-d", + parentThreadId: "root-thread", + parentActivitySequence: 2, + startedAt: "2026-03-09T10:03:00.000Z", + }); + const earlierSequence = makeSidebarThread({ + id: "child-a", + parentThreadId: "root-thread", + parentActivitySequence: 1, + startedAt: "2026-03-09T10:05:00.000Z", + }); + const sameSequenceLaterKey = makeSidebarThread({ + id: "child-c", + parentThreadId: "root-thread", + parentActivitySequence: 2, + startedAt: "2026-03-09T10:02:00.000Z", + }); + const sameSequenceEarlierKey = makeSidebarThread({ + id: "child-b", + parentThreadId: "root-thread", + parentActivitySequence: 2, + startedAt: "2026-03-09T10:02:00.000Z", + }); + + const rendered = flattenSidebarThreadTree({ + allThreads: [ + root, + laterSequence, + earlierSequence, + sameSequenceLaterKey, + sameSequenceEarlierKey, + ], + roots: [root], + }); + + expect(rendered.map(({ thread }) => thread.id)).toEqual([ + ThreadId.make("root-thread"), + ThreadId.make("child-a"), + ThreadId.make("child-b"), + ThreadId.make("child-c"), + ThreadId.make("child-d"), + ]); + }); }); describe("hasUnseenCompletion", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index cb8902ab64d..701487085a2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -320,7 +320,7 @@ export function resolveSidebarRootThread( export function rootSidebarThreads( threads: readonly SidebarThreadSummary[], - allThreads: readonly SidebarThreadSummary[] = threads, + allThreads: readonly SidebarThreadSummary[], ): SidebarThreadSummary[] { const keys = new Set(allThreads.map(sidebarThreadKey)); return threads.filter((thread) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f3db3d67358..88be2a63fc9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -238,17 +238,20 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>(); +function createSidebarThreadByKey(threads: readonly SidebarThreadSummary[]) { + return new Map(threads.map((thread) => [sidebarThreadKey(thread), thread] as const)); +} + function includeSidebarThreadByKey( threads: readonly SidebarThreadSummary[], thread: SidebarThreadSummary | null | undefined, + threadKeys: ReadonlySet<string>, ): readonly SidebarThreadSummary[] { if (!thread) { return threads; } const key = sidebarThreadKey(thread); - return threads.some((candidate) => sidebarThreadKey(candidate) === key) - ? threads - : [...threads, thread]; + return threadKeys.has(key) ? threads : [...threads, thread]; } const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { repository: "Group by repository", @@ -1190,18 +1193,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); const openPrLink = useOpenPrLink(); const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs); + const sidebarThreadKeys = useMemo( + () => new Set(sidebarThreads.map(sidebarThreadKey)), + [sidebarThreads], + ); const projectThreads = useMemo( - () => includeSidebarThreadByKey(sidebarThreads, activeRouteThread), - [activeRouteThread, sidebarThreads], + () => includeSidebarThreadByKey(sidebarThreads, activeRouteThread, sidebarThreadKeys), + [activeRouteThread, sidebarThreadKeys, sidebarThreads], ); const sidebarThreadByKey = useMemo( - () => - new Map( - projectThreads.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), + () => createSidebarThreadByKey(projectThreads), [projectThreads], ); // Keep a ref so callbacks can read the latest map without appearing in @@ -1267,7 +1268,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const { projectStatus, sortedProjectThreads, + sortedProjectThreadByKey, visibleProjectThreads, + visibleProjectThreadByKey, + visibleProjectThreadKeys, visibleRootProjectThreads, orderedProjectThreadKeys, } = useMemo(() => { @@ -1296,11 +1300,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectThreads.filter((thread) => thread.archivedAt === null), threadSortOrder, ); + const sortedProjectThreadByKey = createSidebarThreadByKey(sortedProjectThreads); const visibleProjectThreads = visibleSidebarThreads( sortedProjectThreads, activeProjectThreadPathKeys, ); const visibleProjectThreadKeys = new Set(visibleProjectThreads.map(sidebarThreadKey)); + const visibleProjectThreadByKey = createSidebarThreadByKey(visibleProjectThreads); const visibleRootProjectThreads = rootSidebarThreads( visibleProjectThreads, sortedProjectThreads, @@ -1317,6 +1323,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec orderedProjectThreadKeys, projectStatus, sortedProjectThreads, + sortedProjectThreadByKey, + visibleProjectThreadByKey, + visibleProjectThreadKeys, visibleProjectThreads, visibleRootProjectThreads, }; @@ -1326,8 +1335,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (!activeThreadKey || projectExpanded) { return null; } - return resolveSidebarRootThread(visibleProjectThreads, activeThreadKey); - }, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]); + return resolveSidebarRootThread( + visibleProjectThreads, + activeThreadKey, + visibleProjectThreadByKey, + ); + }, [activeRouteThreadKey, projectExpanded, visibleProjectThreadByKey, visibleProjectThreads]); const { hasOverflowingThreads, @@ -1359,7 +1372,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? visibleRootProjectThreads : visibleRootProjectThreads.slice(0, sidebarThreadPreviewCount); const activeRouteRootThread = activeRouteThreadKey - ? resolveSidebarRootThread(sortedProjectThreads, activeRouteThreadKey) + ? resolveSidebarRootThread( + sortedProjectThreads, + activeRouteThreadKey, + sortedProjectThreadByKey, + ) : null; const visibleThreadKeys = new Set( [ @@ -1376,7 +1393,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const renderedThreads = flattenSidebarThreadTree({ allThreads: sortedProjectThreads, roots: renderedRoots, - visibleThreadKeys: new Set(visibleProjectThreads.map(sidebarThreadKey)), + visibleThreadKeys: visibleProjectThreadKeys, }); const hiddenRootKeys = new Set( visibleRootProjectThreads @@ -1386,7 +1403,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const hiddenThreads = visibleProjectThreads.filter((thread) => hiddenRootKeys.has( sidebarThreadKey( - resolveSidebarRootThread(sortedProjectThreads, sidebarThreadKey(thread)) ?? thread, + resolveSidebarRootThread( + sortedProjectThreads, + sidebarThreadKey(thread), + sortedProjectThreadByKey, + ) ?? thread, ), ), ); @@ -1406,7 +1427,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectExpanded, sidebarThreadPreviewCount, sortedProjectThreads, + sortedProjectThreadByKey, threadLastVisitedAts, + visibleProjectThreadKeys, visibleProjectThreads, visibleRootProjectThreads, ]); @@ -3117,9 +3140,13 @@ export default function Sidebar() { }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const routeThread = useThreadShell(routeThreadRef); + const sidebarThreadKeys = useMemo( + () => new Set(sidebarThreads.map(sidebarThreadKey)), + [sidebarThreads], + ); const sidebarThreadsWithRoute = useMemo( - () => includeSidebarThreadByKey(sidebarThreads, routeThread), - [routeThread, sidebarThreads], + () => includeSidebarThreadByKey(sidebarThreads, routeThread, sidebarThreadKeys), + [routeThread, sidebarThreadKeys, sidebarThreads], ); const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef @@ -3195,13 +3222,7 @@ export default function Sidebar() { [sidebarProjects], ); const sidebarThreadByKey = useMemo( - () => - new Map( - sidebarThreadsWithRoute.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), + () => createSidebarThreadByKey(sidebarThreadsWithRoute), [sidebarThreadsWithRoute], ); // Resolve the active route's project key to a logical key so it matches the @@ -3402,6 +3423,7 @@ export default function Sidebar() { sidebarThreadSortOrder, ); const projectThreads = visibleSidebarThreads(sortedProjectThreads, activeSidebarPathKeys); + const projectThreadByKey = createSidebarThreadByKey(projectThreads); const visibleProjectThreadKeys = new Set(projectThreads.map(sidebarThreadKey)); const rootProjectThreads = rootSidebarThreads(projectThreads, sortedProjectThreads); const projectExpanded = resolveProjectExpanded( @@ -3411,7 +3433,7 @@ export default function Sidebar() { const activeThreadKey = routeThreadKey ?? undefined; const pinnedCollapsedThread = !projectExpanded && activeThreadKey - ? resolveSidebarRootThread(projectThreads, activeThreadKey) + ? resolveSidebarRootThread(projectThreads, activeThreadKey, projectThreadByKey) : null; const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; if (!shouldShowThreadPanel) { @@ -3424,7 +3446,7 @@ export default function Sidebar() { ? rootProjectThreads : rootProjectThreads.slice(0, sidebarThreadPreviewCount); const activeRouteRootThread = activeThreadKey - ? resolveSidebarRootThread(projectThreads, activeThreadKey) + ? resolveSidebarRootThread(projectThreads, activeThreadKey, projectThreadByKey) : null; const renderedRootKeys = new Set( [ diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 3657f2e6b8f..3a37cb77d03 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -117,6 +117,33 @@ beforeEach(() => { }; }); +describe("subagentRelationMatchesBlock", () => { + it("requires matching turn ids when matching reusable parent item ids", async () => { + const { subagentRelationMatchesBlock } = await import("./MessagesTimeline"); + + expect( + subagentRelationMatchesBlock({ + parentItemId: "call-send-input", + parentTurnId: TurnId.make("turn-newer"), + relationParentItemId: "call-send-input", + relationParentTurnId: TurnId.make("turn-older"), + }), + ).toBe(false); + }); + + it("falls back to turn matching when either parent item id is absent", async () => { + const { subagentRelationMatchesBlock } = await import("./MessagesTimeline"); + + expect( + subagentRelationMatchesBlock({ + parentTurnId: TurnId.make("turn-followup"), + relationParentItemId: "call-send-input", + relationParentTurnId: TurnId.make("turn-followup"), + }), + ).toBe(true); + }); +}); + function buildProps() { return { isWorking: false, @@ -527,6 +554,72 @@ describe("MessagesTimeline", () => { expect(markup).toContain("duration unknown"); }); + it("does not reuse running subagent status for a reused item id from another turn", async () => { + const childThreadId = ThreadId.make("subagent-child-1"); + storeMock.state = { + threadShellByKey: { + [`${ACTIVE_THREAD_ENVIRONMENT_ID}\0${childThreadId}`]: { + id: childThreadId, + title: "Say hi briefly", + parentRelation: { + kind: "subagent", + rootThreadId: ThreadId.make("thread-1"), + parentThreadId: ThreadId.make("thread-1"), + parentTurnId: TurnId.make("turn-older"), + parentItemId: "call-send-input", + parentActivitySequence: 2, + providerThreadId: "provider-child-1", + titleSeed: "Say hi in German", + depth: 1, + startedAt: "2026-03-17T19:12:35.000Z", + completedAt: null, + status: "running", + }, + }, + }, + }; + + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + <MessagesTimeline + {...buildProps()} + activeTurnInProgress={true} + latestTurn={{ + turnId: TurnId.make("turn-newer"), + state: "running", + startedAt: "2026-03-17T19:12:30.000Z", + completedAt: null, + }} + timelineEntries={[ + { + id: "entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:30.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:30.000Z", + turnId: TurnId.make("turn-newer"), + label: "Subagent", + tone: "tool", + itemType: "collab_agent_tool_call", + subagentChildren: [ + { + threadId: childThreadId, + parentItemId: "call-send-input", + titleSeed: "Say hi in German", + }, + ], + }, + }, + ]} + />, + ); + + expect(markup).toContain("Subagent - Say hi briefly"); + expect(markup).not.toContain("Working"); + expect(markup).toContain("duration unknown"); + }); + it("renders file review comments as source code instead of diffs", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index faa0523b6df..100c0d6355c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1791,6 +1791,29 @@ const SubagentWorkEntryRows = memo(function SubagentWorkEntryRows({ ); }); +export function subagentRelationMatchesBlock(input: { + parentItemId?: string | null; + parentTurnId?: TurnId | null; + relationParentItemId?: string | null; + relationParentTurnId?: TurnId | null; +}): boolean { + const parentItemId = input.parentItemId ?? null; + const relationParentItemId = input.relationParentItemId ?? null; + const parentTurnId = input.parentTurnId ?? null; + const relationParentTurnId = input.relationParentTurnId ?? null; + + if (parentItemId && relationParentItemId) { + if (parentItemId !== relationParentItemId) { + return false; + } + // Provider item ids can repeat across turns, so a known turn mismatch must + // keep a stale relation from claiming a newer work-log block. + return parentTurnId || relationParentTurnId ? parentTurnId === relationParentTurnId : true; + } + + return parentTurnId || relationParentTurnId ? parentTurnId === relationParentTurnId : true; +} + const SubagentWorkEntryButton = memo(function SubagentWorkEntryButton(props: { parentCreatedAt: string; parentItemId?: string; @@ -1813,12 +1836,12 @@ const SubagentWorkEntryButton = memo(function SubagentWorkEntryButton(props: { } | null>(null); const relationParentItemId = relation?.parentItemId ?? null; const relationParentTurnId = relation?.parentTurnId ?? null; - const relationMatchesThisBlock = - props.parentItemId && relationParentItemId - ? props.parentItemId === relationParentItemId - : props.parentTurnId || relationParentTurnId - ? props.parentTurnId === relationParentTurnId - : true; + const relationMatchesThisBlock = subagentRelationMatchesBlock({ + parentItemId: props.parentItemId ?? null, + parentTurnId: props.parentTurnId ?? null, + relationParentItemId, + relationParentTurnId, + }); if (relation && relationMatchesThisBlock && relation.status !== "running") { terminalSnapshotRef.current = { status: relation.status, From 498a1301af8f73895b0be3b78ce0202103c47df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Sat, 20 Jun 2026 16:01:38 +0100 Subject: [PATCH 14/15] Preserve legacy subagent item matches - Keep matching parent item ids when work-log turn ids are missing - Add regression coverage for item-id-only subagent rows --- .../src/components/chat/MessagesTimeline.test.tsx | 13 +++++++++++++ apps/web/src/components/chat/MessagesTimeline.tsx | 6 ++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 3a37cb77d03..81c4d11c3c0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -131,6 +131,19 @@ describe("subagentRelationMatchesBlock", () => { ).toBe(false); }); + it("keeps matching parent item ids when the work-log turn id is missing", async () => { + const { subagentRelationMatchesBlock } = await import("./MessagesTimeline"); + + expect( + subagentRelationMatchesBlock({ + parentItemId: "call-send-input", + parentTurnId: null, + relationParentItemId: "call-send-input", + relationParentTurnId: TurnId.make("turn-followup"), + }), + ).toBe(true); + }); + it("falls back to turn matching when either parent item id is absent", async () => { const { subagentRelationMatchesBlock } = await import("./MessagesTimeline"); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 100c0d6355c..d4e39f314ea 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1801,6 +1801,8 @@ export function subagentRelationMatchesBlock(input: { const relationParentItemId = input.relationParentItemId ?? null; const parentTurnId = input.parentTurnId ?? null; const relationParentTurnId = input.relationParentTurnId ?? null; + const turnIdsConflict = + parentTurnId && relationParentTurnId && parentTurnId !== relationParentTurnId; if (parentItemId && relationParentItemId) { if (parentItemId !== relationParentItemId) { @@ -1808,10 +1810,10 @@ export function subagentRelationMatchesBlock(input: { } // Provider item ids can repeat across turns, so a known turn mismatch must // keep a stale relation from claiming a newer work-log block. - return parentTurnId || relationParentTurnId ? parentTurnId === relationParentTurnId : true; + return !turnIdsConflict; } - return parentTurnId || relationParentTurnId ? parentTurnId === relationParentTurnId : true; + return !turnIdsConflict; } const SubagentWorkEntryButton = memo(function SubagentWorkEntryButton(props: { From 62c9a5451cb3f50a71d939ab10c49ec1af0b9813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= <quicksaver@gmail.com> Date: Sat, 20 Jun 2026 16:03:38 +0100 Subject: [PATCH 15/15] Update subagent threading docs - Document sidebar structural traversal fixes - Capture parent item and turn matching semantics --- SUBAGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SUBAGENTS.md b/SUBAGENTS.md index 25fe4f06827..bbf07d57b8b 100644 --- a/SUBAGENTS.md +++ b/SUBAGENTS.md @@ -74,7 +74,7 @@ Review fixes added preservation guards so a normal root/default projection upser ## Web Implementation -1. Sidebar nesting is driven by `parentRelation`. Active subagents render under their direct parent only. Terminal subagents are omitted from the sidebar during normal parent browsing, but the currently open terminal child path remains visible and indented while that child or nested descendant is selected. +1. Sidebar nesting is driven by `parentRelation`. Active subagents render under their direct parent only. Terminal subagents are omitted from the sidebar during normal parent browsing, but the currently open terminal child path remains visible and indented while that child or nested descendant is selected. Sidebar tree construction keeps the full non-archived parent chain as structural context before applying terminal-row visibility, so a running descendant does not get promoted to a root row just because an intermediate terminal ancestor is hidden. The currently routed child shell is also included in the sidebar's in-memory route context when available, which preserves active-path visibility while hidden thread details are still catching up to the normal sidebar list. 2. Conversation detail routing accepts hidden child threads through projected/synthetic shells. A child thread can be opened from its parent block even after it has disappeared from the active sidebar. @@ -86,7 +86,7 @@ Review fixes added preservation guards so a normal root/default projection upser 6. Child conversation views replace the normal composer with a subagent control bar. Users cannot send prompts to a subagent. While a child is running, the available user control is stop. The chat header also includes an up-navigation button that opens the direct parent conversation. -7. Review fixes removed duplicate compact subagent rows from Codex control sequences such as `wait` and `closeAgent`. Parent timelines now de-dupe child reference rows by child thread id plus parent collab item id, so control repeats collapse while each prompt-bearing resumed child activity with a new parent item id renders as a new appended block, even when multiple activities happen in the same parent turn. +7. Review fixes removed duplicate compact subagent rows from Codex control sequences such as `wait` and `closeAgent`. Parent timelines now de-dupe child reference rows by child thread id plus parent collab item id, so control repeats collapse while each prompt-bearing resumed child activity with a new parent item id renders as a new appended block, even when multiple activities happen in the same parent turn. Subagent row live-status matching treats the parent collab item id as authoritative when present, also requires turn agreement when both sides know the turn id, and falls back to turn matching for legacy rows where one side lacks the item id. 8. Shared subagent display helpers keep duration and fallback labels consistent across parent blocks and child controls. Terminal child rows with missing completion timestamps show an explicit unknown-duration fallback instead of implying successful completion, and active children use `working` wording instead of `running` wording. @@ -111,7 +111,7 @@ The implementation and review fixes have been covered by focused automated tests - Server tests cover Codex subagent ingestion, parent-collab child shell synthesis, child terminal status, parent-relation persistence, projection upsert preservation, child stop/interrupt routing through the provider-bound root session without root-turn fallback, and archive/delete lifecycle cascades through subagent descendants. - Server tests cover raw subagent prompt projection into child threads, including start-then-complete late prompt updates and whitespace-only prompt suppression. -- Web tests cover sidebar/thread state behavior, duplicate parent subagent control-row removal, same-turn resumed child activity rows, child composer suppression, subagent stop control behavior, and duration fallback labels. +- Web tests cover sidebar/thread state behavior, hidden terminal ancestor traversal, active terminal child path retention, subagent sibling ordering, duplicate parent subagent control-row removal, same-turn resumed child activity rows, child composer suppression, subagent stop control behavior, and duration fallback labels. - Client-runtime tests cover shared idle retention for stream-backed thread state across short subscriber gaps. - Playwright checked Codex subagent behavior with marker prompts: before the prompt projection fix, the child view showed the output marker but not the initial prompt marker; after the fix, the child view showed the initial prompt marker followed by the output marker. Earlier Playwright coverage also checked that the parent showed exactly one compact subagent block, child output/actions did not leak into the parent, the child view was reachable from the parent block, the child view showed the child command/output, and the child view did not expose a prompt composer.