diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index e7e21c1d..438352ba 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 3eb283f1..a8496bc8 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 5fa947df..b8429cce 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 1f802692..5b23d5a5 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 d053788b..8b080cd4 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 64dddf02..38048591 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 ? (