From d3071275d576bb289a6cb177c6cc28fff8e95015 Mon Sep 17 00:00:00 2001 From: scratchyone Date: Sat, 3 Oct 2026 17:12:09 -0700 Subject: [PATCH] fix(web): subagents sent a follow-up show as running in Lineage (#15334) Co-authored-by: scratchyone <11479077+scratchyone@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- .../chat/ThreadRelationshipsControl.tsx | 41 +++++++++++++++++-- .../src/state/threadRelationships.test.ts | 31 ++++++++++++++ .../src/state/threadRelationships.ts | 5 ++- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 418f917a3f617a6af13ff33738d7779867e12c0a..b3e90485b91938a6ec608b7b443fdd3bf3043fae 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -5,7 +5,10 @@ import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { projectedSubagentsToRuntime } from "@t3tools/client-runtime/state/subagentRuntime"; +import { + projectedSubagentsToRuntime, + type RuntimeSubagent, +} from "@t3tools/client-runtime/state/subagentRuntime"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { deriveThreadRelationshipGraph, @@ -23,6 +26,7 @@ import { } from "@t3tools/client-runtime/state/thread-workflows"; import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; import { groupBy } from "effect/Array"; +import * as DateTime from "effect/DateTime"; import { useNavigate } from "@tanstack/react-router"; import { ArrowRightIcon, @@ -162,6 +166,30 @@ function relationshipThreadTitle(input: { return formatSubagentDisplayTitle(input.title); } +/** + * A delegated task settles with its first run, but the parent can keep sending + * the child follow-ups. While the child thread has a live run, the row's timer + * and hover card follow that run instead of the settled task. + */ +function liveSubagent( + agent: Agent | undefined, + childThread: OrchestrationV2ThreadShell | null | undefined, +): Agent | undefined { + const liveStatus = childThread?.activityRunStatus; + if (!agent || !liveStatus) return agent; + const startedAt = childThread.activityRunStartedAt; + return { + ...agent, + status: liveStatus === "running" || liveStatus === "waiting" ? liveStatus : "pending", + startedAt: startedAt ? DateTime.formatIso(startedAt) : null, + completedAt: null, + // The settled task's output belongs to its first run, not this one. + progress: null, + result: null, + error: null, + }; +} + export function ThreadRelationshipsPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -239,9 +267,11 @@ export function ThreadRelationshipsPanel(props: { { id: "active", label: null, rows: active, expanded: true }, { id: "previous", label: "Previous agents", rows: previous, expanded: false }, ]; + // Subagents without a child thread yet have no row, so count them separately. const runningCount = - projection?.subagents.filter((agent) => agent.status === "running").length ?? - active.filter(({ edge }) => edge.status === "running").length; + (projection?.subagents.filter( + (agent) => agent.childThreadId === null && agent.status === "running", + ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length; if (relationshipRows.length === 0 && runningCount === 0) { return null; @@ -330,7 +360,10 @@ export function ThreadRelationshipsPanel(props: { ? BotIcon : GitForkIcon; const relationship = relationshipLabel(edge, props.threadId); - const agent = isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined; + const agent = liveSubagent( + isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined, + node?.thread, + ); const threadTitle = relationshipThreadTitle({ title: node?.thread?.title ?? agent?.title ?? threadId, isSubagent, diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index 7455d9148c0975dbb7e080b12e8a9720824a6208..d96fba5aab2251ab6fcfd2994d85f658061bf53d 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -187,6 +187,37 @@ describe("thread relationships", () => { ); }); + it.each([ + ["running", "running"], + [null, "completed"], + ])( + "shows a subagent whose child activity is %s as %s after its delegated task settled", + (childActivity, expected) => { + const parent = ThreadId.make("thread-parent"); + const child = ThreadId.make("thread-child"); + const graph = deriveThreadRelationshipGraph({ + threads: [ + { id: parent, status: "completed", forkedFrom: null, lineage: { parentThreadId: null } }, + { + id: child, + status: childActivity ?? "completed", + activityRunStatus: childActivity, + forkedFrom: null, + lineage: { parentThreadId: parent, relationshipToParent: "subagent" }, + }, + ] as never, + projection: { + thread: { id: parent }, + subagents: [{ childThreadId: child, status: "completed" }], + contextTransfers: [], + } as never, + }); + + const row = immediateThreadRelationships(graph, parent)[0]!; + expect(threadRelationshipRowStatus(graph, row)).toBe(expected); + }, + ); + it("keeps the live shell when an archived snapshot contains the same thread id", () => { const parent = ThreadId.make("thread-parent"); const staleParent = ThreadId.make("thread-stale-parent"); diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index 93ed8e9973f3e7971850f9ae8c21c6eda0dba424..4ef792a6ada79dd1898a15e0ce263ad234993f5b 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -92,11 +92,14 @@ export function deriveThreadRelationshipGraph(input: { const ownerThreadId = input.projection.thread.id; for (const subagent of input.projection.subagents) { if (subagent.childThreadId === null) continue; + // The subagent record settles with the delegated task's first run, but the + // parent can keep sending the child follow-ups. A live run on the child + // thread outranks that settled status. addEdge({ sourceThreadId: ownerThreadId, targetThreadId: subagent.childThreadId, kind: "subagent", - status: subagent.status, + status: threadsById.get(subagent.childThreadId)?.activityRunStatus ?? subagent.status, }); } for (const transfer of input.projection.contextTransfers) { -- 2.54.0