From 77385671f347f1864036f12ee871651c01cd20b2 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:47:27 -0400 Subject: [PATCH] fix(web): subagent transcript always shows the prompt it was spawned with The instruction block above a subagent's transcript only rendered when the loaded page began at the transcript's first record. The panel opens on the newest 60 records and a working agent's transcript runs into the hundreds, so the block almost never appeared. The server also capped every record at 4,000 characters, while real spawn prompts run past 10,000. The panel now reads the first record once when the newest page starts past it, pins the instruction above the "Load earlier" control, and shows the objective fallback regardless of which page is visible. On the server, text sent to the agent keeps up to 32,000 characters; the agent's own output keeps the existing cap. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 9 ++ .../src/provider/Layers/ClaudeAdapter.ts | 14 +- .../chat/SubagentTranscript.browser.tsx | 29 ++++ .../chat/SubagentTranscript.logic.test.ts | 34 +++-- .../chat/SubagentTranscript.logic.ts | 35 +++-- .../components/chat/SubagentTranscript.tsx | 134 ++++++++++++------ 6 files changed, 183 insertions(+), 72 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index e7e21c1d6..438352ba9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -464,6 +464,15 @@ describe("mapClaudeSubagentTranscript", () => { const capped = mapClaudeSubagentTranscript(longLine); assert.equal(capped.entries[0]?.text.length, 4_000); + // A spawn prompt is read whole, so text sent to the agent keeps far more + // than the agent's own output does. + const longPrompt = transcriptLine({ + type: "user", + message: { content: "p".repeat(40_000) }, + }); + const cappedPrompt = mapClaudeSubagentTranscript(longPrompt); + assert.equal(cappedPrompt.entries[0]?.text.length, 32_000); + const many = Array.from({ length: 5 }, () => transcriptLine({ type: "assistant", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 3eb283f1e..a8496bc8f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2104,7 +2104,13 @@ function extractContentBlockText(block: unknown): string { const SUBAGENT_LIVE_TEXT_MAX_CHARS = 4_000; const SUBAGENT_TRANSCRIPT_DEFAULT_LIMIT = 200; +/** Cap on the agent's own words per record; long output truncates rather than + * growing transcript pages unboundedly. */ const SUBAGENT_TRANSCRIPT_TEXT_MAX_CHARS = 4_000; +/** Cap on text sent *to* the agent: its spawn prompt and any follow-up + * message. A spawn prompt routinely runs past 10k characters and is the one + * record a reader opens the transcript to read whole, so it keeps far more. */ +const SUBAGENT_TRANSCRIPT_INPUT_TEXT_MAX_CHARS = 32_000; const SUBAGENT_TRANSCRIPT_OUTPUT_PREVIEW_MAX_CHARS = 2_000; /** Agent ids come from the client and end up in a filesystem path; anything * outside this shape is rejected before it can traverse. */ @@ -2336,9 +2342,13 @@ export function mapClaudeSubagentTranscriptLines( options?.onModel?.(recordModel); } const content = (message as { content?: unknown } | undefined)?.content; + const textMaxChars = + type === "user" + ? SUBAGENT_TRANSCRIPT_INPUT_TEXT_MAX_CHARS + : SUBAGENT_TRANSCRIPT_TEXT_MAX_CHARS; if (typeof content === "string") { - const text = capTranscriptText(content, SUBAGENT_TRANSCRIPT_TEXT_MAX_CHARS); + const text = capTranscriptText(content, textMaxChars); if (text.length > 0) { push({ role: type, text, ...(at ? { at } : {}), toolUses: [] }); } @@ -2405,7 +2415,7 @@ export function mapClaudeSubagentTranscriptLines( } } - const text = capTranscriptText(texts.join("\n"), SUBAGENT_TRANSCRIPT_TEXT_MAX_CHARS); + const text = capTranscriptText(texts.join("\n"), textMaxChars); const outputPreview = capTranscriptText( resultPreviews.join("\n"), SUBAGENT_TRANSCRIPT_OUTPUT_PREVIEW_MAX_CHARS, diff --git a/apps/web/src/components/chat/SubagentTranscript.browser.tsx b/apps/web/src/components/chat/SubagentTranscript.browser.tsx index 5fa947dfa..b8429ccec 100644 --- a/apps/web/src/components/chat/SubagentTranscript.browser.tsx +++ b/apps/web/src/components/chat/SubagentTranscript.browser.tsx @@ -267,6 +267,35 @@ describe("SubagentTranscript drill-in", () => { expect(instructionBlockText()).toBeNull(); }); + it("reads the spawn prompt separately when the newest page starts past it", async () => { + // A working agent's transcript runs to hundreds of records, so the page the + // panel opens on is nowhere near the prompt. + const [spawnPrompt, ...laterEntries] = ENTRIES; + transcriptRpcMock.mockImplementation( + async (input: { readonly offset?: number; readonly limit?: number }) => + input.offset === 0 && input.limit === 1 + ? { entries: [spawnPrompt], truncated: true, offset: 0, totalEntries: 300 } + : { entries: laterEntries, truncated: true, offset: 296, totalEntries: 300 }, + ); + renderTranscript({ objective: "Spawned to count the files." }); + await expect + .element(page.getByText("Count the direct TypeScript files."), { timeout: 5_000 }) + .toBeVisible(); + + expect(instructionBlockText()).toContain("Count the direct TypeScript files."); + expect(instructionBlockText()).not.toContain("Spawned to count the files."); + // The prompt is the block above the thread and nothing else: it is not + // also a step, and it sits above the paging control rather than under it. + expect(document.querySelectorAll("[data-subagent-transcript-entry='user']")).toHaveLength(1); + const instructionBlock = document.querySelector( + "[data-subagent-transcript-instruction='true']", + ); + const loadEarlier = page.getByRole("button", { name: "Load earlier" }).element(); + expect( + instructionBlock!.compareDocumentPosition(loadEarlier) & Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + }); + it("wraps a long first line under the timestamp instead of into it", async () => { renderTranscript(); await expect.element(page.getByText(LONG_PROSE)).toBeVisible(); diff --git a/apps/web/src/components/chat/SubagentTranscript.logic.test.ts b/apps/web/src/components/chat/SubagentTranscript.logic.test.ts index 1f802692c..5b23d5a59 100644 --- a/apps/web/src/components/chat/SubagentTranscript.logic.test.ts +++ b/apps/web/src/components/chat/SubagentTranscript.logic.test.ts @@ -338,6 +338,26 @@ describe("splitSubagentTranscriptLead", () => { expect(splitSubagentTranscriptLead(view, false).lead).toBeNull(); expect(splitSubagentTranscriptLead(view, false).steps).toHaveLength(2); }); + + it("takes the separately read first record as the lead of a mid-transcript page", () => { + const view = buildSubagentTranscriptView([step], 40); + const spawnPrompt = entry({ + role: "user", + text: "Survey the repo.", + at: "2026-08-11T10:00:00.000Z", + }); + + const { lead, steps } = splitSubagentTranscriptLead(view, false, spawnPrompt); + expect(lead).toMatchObject({ + role: "user", + text: "Survey the repo.", + at: "2026-08-11T10:00:00.000Z", + }); + expect(steps).toHaveLength(1); + + // A forked child's first record is its own work, which is no instruction. + expect(splitSubagentTranscriptLead(view, false, step).lead).toBeNull(); + }); }); describe("resolveSubagentTranscriptInstruction", () => { @@ -351,16 +371,16 @@ describe("resolveSubagentTranscriptInstruction", () => { ).lead; it("prefers the transcript's own leading message", () => { - expect(resolveSubagentTranscriptInstruction(lead("user"), "Spawn objective", true)).toEqual({ + expect(resolveSubagentTranscriptInstruction(lead("user"), "Spawn objective")).toEqual({ text: "Survey the repo.", at: "2026-08-11T10:00:00.000Z", label: "Instruction", }); - expect(resolveSubagentTranscriptInstruction(lead("system"), null, true)?.label).toBe("System"); + expect(resolveSubagentTranscriptInstruction(lead("system"), null)?.label).toBe("System"); }); it("stands in the objective when the transcript has no leading message", () => { - expect(resolveSubagentTranscriptInstruction(null, " Survey the repo. ", true)).toEqual({ + expect(resolveSubagentTranscriptInstruction(null, " Survey the repo. ")).toEqual({ text: "Survey the repo.", at: null, label: "Instruction", @@ -368,11 +388,7 @@ describe("resolveSubagentTranscriptInstruction", () => { }); it("shows nothing without a leading message or an objective", () => { - expect(resolveSubagentTranscriptInstruction(null, null, true)).toBeNull(); - expect(resolveSubagentTranscriptInstruction(null, " ", true)).toBeNull(); - }); - - it("claims no beginning on a page that starts mid-transcript", () => { - expect(resolveSubagentTranscriptInstruction(null, "Survey the repo.", false)).toBeNull(); + expect(resolveSubagentTranscriptInstruction(null, null)).toBeNull(); + expect(resolveSubagentTranscriptInstruction(null, " ")).toBeNull(); }); }); diff --git a/apps/web/src/components/chat/SubagentTranscript.logic.ts b/apps/web/src/components/chat/SubagentTranscript.logic.ts index d053788bc..8b080cd48 100644 --- a/apps/web/src/components/chat/SubagentTranscript.logic.ts +++ b/apps/web/src/components/chat/SubagentTranscript.logic.ts @@ -406,27 +406,38 @@ export function buildSubagentTranscriptActivityRun( }; } +export type SubagentTranscriptLead = Extract; + /** The prompt an agent was spawned with is context, not a step it took, so it * is lifted out of the thread and shown above it. Only the very first entry of * the transcript qualifies: a later instruction is a mid-run message to the * agent and belongs on the thread with everything else. * * @param atTranscriptStart False when the page starts mid-transcript, where - * the first visible item is not the spawn prompt. */ + * the first visible item is not the spawn prompt. + * @param firstEntry The transcript's first record, read separately when the + * page starts mid-transcript. The panel opens on the newest page, and a + * working agent's transcript is far longer than one page, so without this + * the prompt would only ever surface after paging all the way back. */ export function splitSubagentTranscriptLead( items: ReadonlyArray, atTranscriptStart: boolean, + firstEntry?: SubagentTranscriptEntryLike | null, ): { - readonly lead: Extract | null; + readonly lead: SubagentTranscriptLead | null; readonly steps: ReadonlyArray; } { + if (!atTranscriptStart) { + const lead = firstEntry + ? splitSubagentTranscriptLead(buildSubagentTranscriptView([firstEntry], 0), true).lead + : null; + return { lead, steps: items }; + } const [first] = items; - if (!atTranscriptStart || first === undefined || first.kind !== "message") { + if (first === undefined || first.kind !== "message" || first.role === "assistant") { return { lead: null, steps: items }; } - return first.role === "assistant" - ? { lead: null, steps: items } - : { lead: first, steps: items.slice(1) }; + return { lead: first, steps: items.slice(1) }; } export interface SubagentTranscriptInstruction { @@ -444,13 +455,12 @@ export interface SubagentTranscriptInstruction { * leading message at all: the objective the agent was spawned with stands in, * being the same information from the only place that still holds it. * - * @param atTranscriptStart False when the page starts mid-transcript, where an - * instruction of any kind would be claiming a beginning that is not on screen. + * The block sits above the thread as the setup for whatever page is showing, + * so which page is showing does not decide whether it appears. */ export function resolveSubagentTranscriptInstruction( - lead: Extract | null, + lead: SubagentTranscriptLead | null, objective: string | null | undefined, - atTranscriptStart: boolean, ): SubagentTranscriptInstruction | null { if (lead) { return { @@ -460,10 +470,7 @@ export function resolveSubagentTranscriptInstruction( }; } const trimmedObjective = objective?.trim(); - if (!atTranscriptStart || !trimmedObjective) { - return null; - } - return { text: trimmedObjective, at: null, label: "Instruction" }; + return trimmedObjective ? { text: trimmedObjective, at: null, label: "Instruction" } : null; } /** The provider only writes a transcript record once a message completes, so a diff --git a/apps/web/src/components/chat/SubagentTranscript.tsx b/apps/web/src/components/chat/SubagentTranscript.tsx index 64dddf021..38048591f 100644 --- a/apps/web/src/components/chat/SubagentTranscript.tsx +++ b/apps/web/src/components/chat/SubagentTranscript.tsx @@ -55,16 +55,19 @@ const EMPTY_WORK_ENTRIES: ReadonlyArray = []; * following, small enough that a deliberate scroll up releases. */ const BOTTOM_STICK_THRESHOLD_PX = 48; +interface SubagentTranscriptSection { + readonly agentId: string; + readonly result: ProviderSubagentTranscriptResult; + /** The transcript's first record, read separately when the loaded page + * starts past it. Null once read and found to be no instruction; undefined + * while unknown. */ + readonly firstEntry?: ProviderSubagentTranscriptEntry | null; +} + type SubagentTranscriptFetchState = | { readonly status: "loading" } | { readonly status: "error"; readonly message: string } - | { - readonly status: "loaded"; - readonly sections: ReadonlyArray<{ - readonly agentId: string; - readonly result: ProviderSubagentTranscriptResult; - }>; - }; + | { readonly status: "loaded"; readonly sections: ReadonlyArray }; interface SubagentTranscriptProps { environmentId: EnvironmentId; @@ -179,30 +182,25 @@ function mergeTranscriptPages( } function mergeTranscriptSections( - current: ReadonlyArray<{ - readonly agentId: string; - readonly result: ProviderSubagentTranscriptResult; - }>, - incoming: ReadonlyArray<{ - readonly agentId: string; - readonly result: ProviderSubagentTranscriptResult; - }>, -) { + current: ReadonlyArray, + incoming: ReadonlyArray, +): ReadonlyArray { const currentByAgentId = new Map(current.map((section) => [section.agentId, section])); return incoming.map((section) => { const existing = currentByAgentId.get(section.agentId); - return existing - ? { ...section, result: mergeTranscriptPages(existing.result, section.result) } - : section; + if (!existing) { + return section; + } + const firstEntry = section.firstEntry ?? existing.firstEntry; + return { + ...section, + result: mergeTranscriptPages(existing.result, section.result), + ...(firstEntry !== undefined ? { firstEntry } : {}), + }; }); } -function transcriptRevision( - sections: ReadonlyArray<{ - readonly agentId: string; - readonly result: ProviderSubagentTranscriptResult; - }>, -): string { +function transcriptRevision(sections: ReadonlyArray): string { return sections .map((section) => { const lastEntry = section.result.entries.at(-1); @@ -256,18 +254,53 @@ export function SubagentTranscript({ let refreshTimeoutId: ReturnType | null = null; setState({ status: "loading" }); - const readLatestSections = async () => { - return await Promise.all( - requestedAgentIds.map(async (agentId) => ({ + // The spawn prompt is the transcript's first record, which the newest page + // stops short of on any agent that has done real work. It never changes, so + // it is read once per agent and then rides along on every refresh. + const firstEntries = new Map(); + const readFirstEntry = async ( + agentId: string, + result: ProviderSubagentTranscriptResult, + ): Promise => { + const known = firstEntries.get(agentId); + if (known !== undefined) { + return known; + } + // Cursor-only providers carry no offset and no leading message to read. + if (result.offset === undefined || result.offset === 0) { + return undefined; + } + try { + const firstPage = await readSubagentTranscriptPage({ + environmentId, + threadId, agentId, - result: await readSubagentTranscriptPage({ + offset: 0, + limit: 1, + }); + const firstEntry = firstPage.offset === 0 ? (firstPage.entries[0] ?? null) : null; + firstEntries.set(agentId, firstEntry); + return firstEntry; + } catch { + // The thread itself loaded; a missing prompt is not worth blanking it. + // Left unknown, the next refresh tries again. + return undefined; + } + }; + + const readLatestSections = async (): Promise> => { + return await Promise.all( + requestedAgentIds.map(async (agentId): Promise => { + const result = await readSubagentTranscriptPage({ environmentId, threadId, agentId, limit: TRANSCRIPT_PAGE_SIZE, fromEnd: true, - }), - })), + }); + const firstEntry = await readFirstEntry(agentId, result); + return { agentId, result, ...(firstEntry !== undefined ? { firstEntry } : {}) }; + }), ); }; @@ -357,7 +390,11 @@ export function SubagentTranscript({ const items = buildSubagentTranscriptView(section.result.entries, section.result.offset ?? 0); const atTranscriptStart = section.result.nextCursor === undefined && (section.result.offset ?? 0) === 0; - const { lead, steps } = splitSubagentTranscriptLead(items, atTranscriptStart); + const { lead, steps } = splitSubagentTranscriptLead( + items, + atTranscriptStart, + section.firstEntry, + ); const activityRun = sectionIndex === 0 ? buildSubagentTranscriptActivityRun(activityEntries, items) : null; return { @@ -372,7 +409,6 @@ export function SubagentTranscript({ instruction: resolveSubagentTranscriptInstruction( lead, sectionIndex === 0 ? objective : null, - atTranscriptStart, ), }; }); @@ -575,6 +611,24 @@ export function SubagentTranscript({ !follow && sectionIndex === sectionViews.length - 1 && terminalNotice !== null; return (
+ {sectionViews.length > 1 ? ( +

+ Agent {section.agentId} +

+ ) : null} + {/* The instruction is the setup for whatever page is showing, + so it stays pinned above the paging control: "load + earlier" then reads as earlier steps, not as something + before the prompt. */} + {instruction ? ( + + ) : null} {section.result.nextCursor !== undefined || (section.result.offset ?? 0) > 0 ? (