diff --git a/SUBAGENTS.md b/SUBAGENTS.md new file mode 100644 index 00000000000..6198c200803 --- /dev/null +++ b/SUBAGENTS.md @@ -0,0 +1,143 @@ +# 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. +- 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. +- 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. 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 - ` 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. 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. + +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 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. + +## 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 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. + +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. +- 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, 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: + +```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. 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. + +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 a08da26ba59..0d31010195d 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..8eb195b79b9 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,389 @@ 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("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.completed", + eventId: asEventId("evt-subagent-resume-followup-completed"), + 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: "completed", + 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: { + 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..780af1f6800 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); } @@ -86,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, @@ -265,12 +386,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 +730,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 +752,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 +784,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 +793,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 +1408,144 @@ 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 startsNewParentActivity = + existingRelation !== null && + existingRelation.parentItemId !== null && + existingRelation.parentItemId !== child.parentItemId; + const restartsRunningChild = + startsNewParentActivity || + (event.type === "item.started" && existingRelation?.status !== "running"); + const parentRelation: SubagentThreadParentRelation = { + kind: "subagent" as const, + rootThreadId, + parentThreadId: thread.id, + 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: startsNewParentActivity + ? child.titleSeed + : (existingRelation?.titleSeed ?? child.titleSeed), + depth: parentDepth + 1, + startedAt: restartsRunningChild ? now : (existingRelation?.startedAt ?? now), + completedAt: restartsRunningChild ? null : (existingRelation?.completedAt ?? null), + status: restartsRunningChild ? "running" : (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); + } 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); + 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 +1679,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.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 0d4af771ca8..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, @@ -239,6 +303,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, }, @@ -251,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({ @@ -273,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({ @@ -335,6 +433,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 +755,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..fc2b1a858b4 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,55 @@ 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 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, 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 +652,89 @@ function rememberCollabReceiverTurns( return; } + 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) { - collabReceiverTurns.set(receiverThreadId, parentTurnId); + const existing = collabReceiverTurns.get(receiverThreadId); + collabReceiverTurns.set(receiverThreadId, { + parentTurnId: startsNewParentActivity + ? parentTurnId + : (existing?.parentTurnId ?? parentTurnId), + parentItemId: startsNewParentActivity + ? parentItemId + : (existing?.parentItemId ?? parentItemId), + providerThreadId: receiverThreadId, + childThreadId: + existing?.childThreadId ?? + deterministicSubagentThreadId({ + parentThreadId, + providerThreadId: receiverThreadId, + }), + rawPrompt: startsNewParentActivity ? rawPrompt : (rawPrompt ?? existing?.rawPrompt), + detail: startsNewParentActivity ? 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 +828,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 +949,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 +991,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 +1013,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 1da0ea27a65..4f047b8a5c5 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 a1ef90c4309..822938a1e8a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -70,9 +70,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, @@ -126,7 +133,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 { cn, randomHex } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -146,7 +153,7 @@ import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { buildDraftThreadRouteParams } from "../threadRoutes"; +import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -285,6 +292,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(); @@ -1108,6 +1165,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>> >({}); @@ -1209,6 +1269,30 @@ function ChatViewContent(props: ChatViewProps) { ); const isServerThread = routeKind === "server" && serverThread !== null; 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 threadError = isServerThread ? (localServerError ?? serverThread?.session?.lastError ?? null) : localDraftError; @@ -3988,6 +4072,9 @@ function ChatViewContent(props: ChatViewProps) { const onInterrupt = async () => { if (!activeThread) return; + if (activeThreadSubagentRelation?.status === "running") { + setPendingSubagentStopThreadId(activeThread.id); + } const result = await interruptThreadTurn({ environmentId, input: { @@ -4811,6 +4898,7 @@ function ChatViewContent(props: ChatViewProps) { onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} + {...(activeThreadParentRef ? { onOpenParentThread: openActiveThreadParent } : {})} /> </header> @@ -4878,79 +4966,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 0d6b4b6d1c9..e4fd466f9d5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -224,6 +224,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", @@ -313,6 +426,7 @@ function buildThreadJumpLabelMap(input: { interface SidebarThreadRowProps { thread: SidebarThreadSummary; + depth?: number; projectCwd: string | null; orderedProjectThreadKeys: readonly string[]; isActive: boolean; @@ -374,6 +488,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); @@ -444,6 +559,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; + const canArchiveThread = thread.parentRelation?.kind !== "subagent"; const threadStatus = resolveThreadStatusPill({ thread: { ...thread, @@ -453,7 +569,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 @@ -660,6 +777,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} @@ -779,7 +897,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 @@ -876,7 +994,7 @@ interface SidebarProjectThreadListProps { hasOverflowingThreads: boolean; hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; + renderedThreads: readonly RenderedSidebarThread[]; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; @@ -976,12 +1094,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} @@ -1232,7 +1351,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)), @@ -1254,15 +1378,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]); const pinnedCollapsedThread = useMemo(() => { @@ -1270,12 +1398,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 { @@ -1302,24 +1425,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, @@ -1327,10 +1474,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, @@ -1338,6 +1486,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec sidebarThreadPreviewCount, threadLastVisitedAts, visibleProjectThreads, + visibleRootProjectThreads, ]); const handleProjectButtonClick = useCallback( @@ -1759,11 +1908,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, ); @@ -2106,6 +2261,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadProject = memberProjectByScopedKey.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), ); + const isSubagentThread = thread.parentRelation?.kind === "subagent"; const threadWorkspacePath = thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; const clicked = await api.contextMenu.show( @@ -2114,7 +2270,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec { 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, ); @@ -3256,12 +3414,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)), @@ -3285,7 +3444,7 @@ export default function Sidebar() { projectPhysicalKeyByScopedRef, sidebarProjectByKey, sidebarProjects, - visibleThreads, + visibleRootThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; const visibleSidebarThreadKeys = useMemo( @@ -3297,6 +3456,7 @@ export default function Sidebar() { ), sidebarThreadSortOrder, ); + const rootProjectThreads = rootSidebarThreads(projectThreads); const projectExpanded = resolveProjectExpanded( projectExpandedById, projectExpansionPreferenceKeys(project), @@ -3304,26 +3464,34 @@ export default function Sidebar() { 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 efc160b0bd1..e1041c7befb 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/environment"; 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, @@ -33,6 +35,7 @@ interface ChatHeaderProps { rightPanelOpen: boolean; gitCwd: string | null; onRunProjectScript: (script: ProjectScript) => void; + onOpenParentThread?: (() => void) | undefined; onAddProjectScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>; onUpdateProjectScript: ( scriptId: string, @@ -70,6 +73,7 @@ export const ChatHeader = memo(function ChatHeader({ onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, + onOpenParentThread, }: ChatHeaderProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const showOpenInPicker = shouldShowOpenInPicker({ @@ -81,19 +85,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 3207876f706..ce80ddcfc57 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,25 @@ vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff }; }); +const storeMock = vi.hoisted(() => ({ + state: { + threadShellByKey: {}, + } as { + threadShellByKey: Record<string, unknown>; + }, +})); + +vi.mock("../../state/entities", async (importOriginal) => { + const actual = await importOriginal<typeof import("../../state/entities")>(); + return { + ...actual, + useThreadShell: (ref: { environmentId: string; threadId: string } | null) => + ref === null + ? null + : (storeMock.state.threadShellByKey[`${ref.environmentId}\0${ref.threadId}`] ?? null), + }; +}); + function matchMedia() { return { matches: false, @@ -83,6 +102,8 @@ beforeAll(() => { documentElement: { classList, offsetHeight: 0, + removeAttribute: () => {}, + setAttribute: () => {}, }, }); }); @@ -90,6 +111,12 @@ beforeAll(() => { const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; +beforeEach(() => { + storeMock.state = { + threadShellByKey: {}, + }; +}); + function buildProps() { return { isWorking: false, @@ -300,6 +327,73 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("</review_comment>"); }); + 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 = { + 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: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( @@ -364,4 +458,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 b0d83be7b10..2cce9dde866 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/environment"; +import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useNavigate } from "@tanstack/react-router"; import { createContext, Fragment, @@ -30,6 +32,13 @@ import { workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; +import { + formatSubagentDuration, + formatTerminalSubagentStatusDuration, + LiveSubagentDuration, + subagentStatusToneClass, + type SubagentThreadStatus, +} from "../../subagentDisplay"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import { getRenderablePatch, @@ -92,6 +101,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 { useThreadShell } from "../../state/entities"; import { buildInlineTerminalContextText, @@ -1439,10 +1450,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 ?? []; @@ -1468,6 +1494,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)}`); } @@ -1537,6 +1572,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: { @@ -1546,6 +1613,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); @@ -1697,3 +1767,139 @@ 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}:${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 } + : {})} + /> + ))} + </div> + ); +}); + +const SubagentWorkEntryButton = memo(function SubagentWorkEntryButton(props: { + parentCreatedAt: string; + parentItemId?: string; + parentTurnId?: TurnId; + threadId: ThreadId; + titleSeed?: string; +}) { + const ctx = use(TimelineRowCtx); + const navigate = useNavigate(); + const childShell = useThreadShell(scopeThreadRef(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 terminalSnapshotRef = useRef<{ + status: Exclude<SubagentThreadStatus, "running">; + startedAt: string; + completedAt: string | null; + } | null>(null); + const relationParentItemId = relation?.parentItemId ?? null; + const relationParentTurnId = relation?.parentTurnId ?? null; + const relationMatchesThisBlock = + Boolean(props.parentTurnId && props.parentTurnId === relationParentTurnId) || + !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} /> + ) : ( + 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/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index f174ed8e6c6..867f66567e4 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -22,6 +22,7 @@ import { readLocalApi } from "../localApi"; import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; 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"; @@ -31,6 +32,35 @@ export class ThreadArchiveBlockedError extends Data.TaggedError("ThreadArchiveBl readonly message: string; }> {} +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, + ); +} + +function findThreadById<T extends Pick<Thread, "id">>( + threads: readonly T[], + threadId: ThreadId, +): T | null { + return threads.find((thread) => thread.id === threadId) ?? null; +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -85,7 +115,19 @@ export function useThreadActions() { const resolved = resolveThreadTarget(target); if (!resolved) return AsyncResult.success(undefined); const { thread, threadRef } = resolved; - if (thread.session?.status === "running" && thread.session.activeTurnId != null) { + const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { + const shell = readThreadShell(ref); + return shell === null ? [] : [shell]; + }); + const archivedThreadIds = collectLifecycleThreadIds(threads, new Set([threadRef.threadId])); + if ( + threads.some( + (entry) => + archivedThreadIds.has(entry.id) && + entry.session?.status === "running" && + entry.session.activeTurnId != null, + ) + ) { return AsyncResult.failure( Cause.fail( new ThreadArchiveBlockedError({ @@ -97,14 +139,17 @@ export function useThreadActions() { const currentRouteThreadRef = getCurrentRouteThreadRef(); const shouldNavigateToDraft = - currentRouteThreadRef?.threadId === threadRef.threadId && - currentRouteThreadRef.environmentId === threadRef.environmentId; - const archiveResult = await archiveThreadMutation({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId }, - }); - if (archiveResult._tag === "Failure") { - return archiveResult; + currentRouteThreadRef?.environmentId === threadRef.environmentId && + archivedThreadIds.has(currentRouteThreadRef.threadId); + + for (const archivedThreadId of withRootLast(archivedThreadIds, threadRef.threadId)) { + const archiveResult = await archiveThreadMutation({ + environmentId: threadRef.environmentId, + input: { threadId: archivedThreadId }, + }); + if (archiveResult._tag === "Failure") { + return archiveResult; + } } if (shouldNavigateToDraft) { @@ -115,11 +160,11 @@ export function useThreadActions() { return navigationResult; } refreshArchivedThreadsForEnvironment(threadRef.environmentId); - return archiveResult; + return AsyncResult.success(undefined); } refreshArchivedThreadsForEnvironment(threadRef.environmentId); - return archiveResult; + return AsyncResult.success(undefined); }, [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], ); @@ -161,7 +206,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) => { @@ -170,6 +215,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)) @@ -201,43 +251,57 @@ export function useThreadActions() { shouldDeleteWorktree = confirmationResult.value; } - if (thread.session && thread.session.status !== "stopped") { - await stopThreadSession({ + for (const deletedThreadId of withRootLast(deletedIds, threadRef.threadId)) { + const deletedThread = findThreadById(threads, deletedThreadId); + if (deletedThread?.session && deletedThread.session.status !== "stopped") { + await stopThreadSession({ + environmentId: threadRef.environmentId, + input: { threadId: deletedThreadId }, + }); + } + + await closeTerminal({ environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId }, + input: { threadId: deletedThreadId, deleteHistory: true }, }); } - await closeTerminal({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, deleteHistory: true }, - }); - - 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, }); - const deleteResult = await deleteThreadMutation({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId }, - }); - if (deleteResult._tag === "Failure") { - return deleteResult; + for (const deletedThreadId of withRootLast(deletedIds, threadRef.threadId)) { + const deleteResult = await deleteThreadMutation({ + environmentId: threadRef.environmentId, + input: { threadId: deletedThreadId }, + }); + if (deleteResult._tag === "Failure") { + return deleteResult; + } } refreshArchivedThreadsForEnvironment(threadRef.environmentId); - clearComposerDraftForThread(threadRef); - clearProjectDraftThreadById( - scopeProjectRef(threadRef.environmentId, thread.projectId), - threadRef, - ); - clearTerminalUiState(threadRef); + for (const deletedThreadId of deletedIds) { + const deletedThreadRef = scopeThreadRef(threadRef.environmentId, deletedThreadId); + const deletedThread = findThreadById(threads, deletedThreadId); + clearComposerDraftForThread(deletedThreadRef); + if (deletedThread) { + clearProjectDraftThreadById( + scopeProjectRef(threadRef.environmentId, deletedThread.projectId), + deletedThreadRef, + ); + } + clearTerminalUiState(deletedThreadRef); + } if (shouldNavigateToFallback) { if (fallbackThreadId) { @@ -276,7 +340,7 @@ export function useThreadActions() { } if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { - return deleteResult; + return AsyncResult.success(undefined); } const removeResult = await removeWorktree({ @@ -318,7 +382,7 @@ export function useThreadActions() { ); return cleanupFailure; } - return deleteResult; + return AsyncResult.success(undefined); }, [ clearComposerDraftForThread, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..3c4391132d1 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1214,6 +1214,505 @@ 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("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", + turnId: "turn-followup", + 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("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 5d5051f748e..9c250fa1790 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,12 @@ export interface WorkLogEntry { sourceActivityKind?: OrchestrationThreadActivity["kind"]; } +export interface SubagentWorkLogChild { + threadId: ThreadId; + parentItemId?: string; + titleSeed?: string; +} + interface DerivedWorkLogEntry extends WorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; @@ -637,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 }); }); @@ -719,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; } @@ -728,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; } @@ -778,6 +804,50 @@ function collapseDerivedWorkLogEntries( return collapsed; } +function dedupeSubagentChildWorkEntries( + entries: ReadonlyArray<DerivedWorkLogEntry>, +): DerivedWorkLogEntry[] { + const seenChildActivityKeys = 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) => { + const activityScope = entry.turnId ?? child.parentItemId ?? ""; + const key = `${child.threadId}:${activityScope}`; + if (seenChildActivityKeys.has(key)) { + return false; + } + seenChildActivityKeys.add(key); + 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, @@ -788,7 +858,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) { @@ -808,23 +885,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 } : {}), @@ -835,6 +932,54 @@ 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 byChildActivity = new Map<string, SubagentWorkLogChild>(); + for (const child of merged) { + const key = `${child.threadId}:${child.parentItemId ?? ""}`; + const existing = byChildActivity.get(key); + const titleSeed = existing?.titleSeed ?? child.titleSeed; + byChildActivity.set(key, { + threadId: child.threadId, + ...(child.parentItemId ? { parentItemId: child.parentItemId } : {}), + ...(titleSeed ? { titleSeed } : {}), + }); + } + return [...byChildActivity.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, @@ -1083,7 +1228,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 { @@ -1210,6 +1362,87 @@ 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); + const parentItemId = asTrimmedString(record?.parentItemId); + result.push({ + threadId: ThreadId.make(rawThreadId), + ...(parentItemId ? { parentItemId } : {}), + ...(titleSeed ? { titleSeed } : {}), + }); + } + return result; +} + function extractWorkLogItemType( payload: Record<string, unknown> | null, ): WorkLogEntry["itemType"] | undefined { @@ -1321,7 +1554,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/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/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 94eb1c65370..9bf2b1a9381 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -5,6 +5,7 @@ import { EventId, MessageId, ProjectId, + ProviderItemId, ProviderInstanceId, ThreadId, TurnId, @@ -100,6 +101,49 @@ describe("applyThreadDetailEvent", () => { expect(result.thread.session).toBeNull(); } }); + + it("preserves subagent parent relation on fresh threads", () => { + const parentRelation = { + kind: "subagent" as const, + rootThreadId: ThreadId.make("thread-root"), + parentThreadId: ThreadId.make("thread-parent"), + parentTurnId: TurnId.make("turn-parent"), + parentItemId: ProviderItemId.make("call-spawn"), + parentActivitySequence: 1, + providerThreadId: "provider-child-1", + titleSeed: "Inspect websocket handling", + depth: 1, + startedAt: "2026-04-01T01:00:00.000Z", + completedAt: null, + status: "running" as const, + }; + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 1, + occurredAt: "2026-04-01T01:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-child"), + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-child"), + projectId: ProjectId.make("project-1"), + title: "Subagent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + parentRelation, + createdAt: "2026-04-01T01:00:00.000Z", + updatedAt: "2026-04-01T01:00:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.parentRelation).toEqual(parentRelation); + } + }); }); describe("thread.deleted", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee70..45f736479d3 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -68,6 +68,9 @@ export function applyThreadDetailEvent( interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + ...(event.payload.parentRelation !== undefined + ? { parentRelation: event.payload.parentRelation } + : {}), latestTurn: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -114,6 +117,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 623fed0917b..dc45713bc32 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({ @@ -725,6 +751,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, @@ -767,6 +803,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, + ThreadMessageUserAppendCommand, ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, @@ -847,6 +884,7 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + parentRelation: Schema.optional(OrchestrationThreadParentRelation), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -873,6 +911,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, });