Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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: [] });
}
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/components/chat/SubagentTranscript.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
34 changes: 25 additions & 9 deletions apps/web/src/components/chat/SubagentTranscript.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -351,28 +371,24 @@ 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",
});
});

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();
});
});
35 changes: 21 additions & 14 deletions apps/web/src/components/chat/SubagentTranscript.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,27 +406,38 @@ export function buildSubagentTranscriptActivityRun(
};
}

export type SubagentTranscriptLead = Extract<SubagentTranscriptViewItem, { kind: "message" }>;

/** 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<SubagentTranscriptViewItem>,
atTranscriptStart: boolean,
firstEntry?: SubagentTranscriptEntryLike | null,
): {
readonly lead: Extract<SubagentTranscriptViewItem, { kind: "message" }> | null;
readonly lead: SubagentTranscriptLead | null;
readonly steps: ReadonlyArray<SubagentTranscriptViewItem>;
} {
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 {
Expand All @@ -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<SubagentTranscriptViewItem, { kind: "message" }> | null,
lead: SubagentTranscriptLead | null,
objective: string | null | undefined,
atTranscriptStart: boolean,
): SubagentTranscriptInstruction | null {
if (lead) {
return {
Expand All @@ -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
Expand Down
Loading
Loading