diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 2d3a970181..f661803383 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -26,6 +26,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => { }; }); import { + buildAcpxRunSummary, createAcpxEngineExecutor, findAncestorBin, geminiVersionSupportsNativeAcpFlag, @@ -627,6 +628,304 @@ describe("shared ACPX engine runtime behavior", () => { }); }); + it("pins the existing summary and tool-event behavior when no engine knobs are set", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const logs: Array<{ stream: string; text: string }> = []; + const execute = createAcpxEngineExecutor({ + createRuntime: () => ({ + ensureSession: async () => ({ + backendSessionId: "backend-session", + agentSessionId: "agent-session", + runtimeSessionName: "runtime-session", + }), + startTurn: () => ({ + events: (async function* () { + yield { + type: "text_delta", + text: "Let me get oriented and inspect the PRs…", + stream: "output", + tag: "agent_message_chunk", + }; + yield { + type: "text_delta", + text: "hidden chain of thought", + stream: "thought", + tag: "agent_thought_chunk", + }; + yield { + type: "tool_call", + text: "Bash (pending)", + title: "Bash", + status: "pending", + toolCallId: "tool-1", + tag: "tool_call", + }; + yield { + type: "tool_call", + text: 'tool call (in_progress): {"command":"', + title: "tool call", + status: "in_progress", + toolCallId: "tool-1", + tag: "tool_call_update", + }; + yield { + type: "tool_call", + text: "tool call (completed): apps", + title: "tool call", + status: "completed", + toolCallId: "tool-1", + tag: "tool_call_update", + }; + yield { + type: "text_delta", + text: "## Update\n\n- Checked PR status\n- Continue burn-in", + stream: "output", + tag: "agent_message_chunk", + }; + yield { type: "done", stopReason: "end_turn" }; + })(), + result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), + cancel: async () => {}, + }), + close: async () => {}, + }) as never, + }); + + const result = await execute({ + runId: "run-default-traits-pin", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir }, + context: {}, + onLog: async (stream: "stdout" | "stderr", text: string) => { + logs.push({ stream, text }); + }, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(0); + // The summary is the full concatenation of every text delta, thought + // stream included — the engine's long-standing behavior for claude, + // codex, gemini, and custom agents. If this assertion breaks, a change + // is altering summaries for existing adapters. + expect(result.summary).toBe( + "Let me get oriented and inspect the PRs…hidden chain of thought## Update\n\n- Checked PR status\n- Continue burn-in", + ); + const toolCallEvents = logs + .map((entry) => { + try { + return JSON.parse(entry.text) as Record; + } catch { + return null; + } + }) + .filter((parsed): parsed is Record => parsed?.type === "acpx.tool_call"); + // Every tool_call update reaches the run log — including in-progress + // updates with the unresolved placeholder title (their name is restored + // from the pending announcement). Nothing is coalesced or dropped for + // agents without the coalescePlaceholderToolUpdates trait. + expect(toolCallEvents.map((event) => [event.name, event.status])).toEqual([ + ["Bash", "pending"], + ["Bash", "in_progress"], + ["Bash", "completed"], + ]); + }); + + it("summarizes only the final output segment when the adapter sets summaryStrategy", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const execute = createAcpxEngineExecutor({ + createRuntime: () => ({ + ensureSession: async () => ({ + backendSessionId: "backend-session", + agentSessionId: "agent-session", + runtimeSessionName: "runtime-session", + }), + startTurn: () => ({ + events: (async function* () { + yield { + type: "text_delta", + text: "Let me get oriented and inspect the PRs…", + stream: "output", + tag: "agent_message_chunk", + }; + yield { + type: "text_delta", + text: "hidden chain of thought", + stream: "thought", + tag: "agent_thought_chunk", + }; + yield { + type: "tool_call", + text: "Bash (pending)", + title: "Bash", + status: "pending", + toolCallId: "tool-1", + tag: "tool_call", + }; + yield { + type: "tool_call", + text: 'tool call (in_progress): {"command":"', + title: "tool call", + status: "in_progress", + toolCallId: "tool-1", + tag: "tool_call_update", + }; + yield { + type: "tool_call", + text: "tool call (completed): apps", + title: "tool call", + status: "completed", + toolCallId: "tool-1", + tag: "tool_call_update", + }; + yield { + type: "text_delta", + text: "## Update\n\n- Checked PR status\n- Continue burn-in", + stream: "output", + tag: "agent_message_chunk", + }; + yield { type: "done", stopReason: "end_turn" }; + })(), + result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), + cancel: async () => {}, + }), + close: async () => {}, + }) as never, + }); + + const result = await execute({ + runId: "run-summary-last-segment", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir, + summaryStrategy: "lastOutputSegment", + }, + context: {}, + onLog: async () => {}, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(0); + // Must not include intermediate narration or thought stream. + expect(result.summary).toBe("## Update\n\n- Checked PR status\n- Continue burn-in"); + expect(result.summary).not.toContain("Let me get oriented"); + expect(result.summary).not.toContain("hidden chain of thought"); + }); + + it("buildAcpxRunSummary prefers the last non-empty segment", () => { + expect( + buildAcpxRunSummary({ + outputSegments: ["first plan", "second plan", " final update "], + fallback: "end_turn", + }), + ).toBe("final update"); + expect(buildAcpxRunSummary({ outputSegments: ["", " "], fallback: "end_turn" })).toBe("end_turn"); + }); + + it("coalesces placeholder-title tool updates when the adapter opts in", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const logs: Array<{ stream: string; text: string }> = []; + const execute = createAcpxEngineExecutor({ + createRuntime: () => ({ + ensureSession: async () => ({ + backendSessionId: "backend-session", + agentSessionId: "agent-session", + runtimeSessionName: "runtime-session", + }), + startTurn: () => ({ + events: (async function* () { + yield { + type: "tool_call", + text: "Bash (pending)", + title: "Bash", + status: "pending", + toolCallId: "tool-1", + tag: "tool_call", + }; + yield { + type: "tool_call", + text: 'tool call (in_progress): {"command":"', + title: "tool call", + status: "in_progress", + toolCallId: "tool-1", + tag: "tool_call_update", + }; + yield { + type: "tool_call", + text: 'tool call (in_progress): {"command":"ls', + title: "tool call", + status: "in_progress", + toolCallId: "tool-1", + tag: "tool_call_update", + }; + yield { + type: "tool_call", + text: "Running: ls apps (in_progress)", + title: "Running: ls apps", + status: "in_progress", + toolCallId: "tool-1", + tag: "tool_call_update", + }; + yield { + type: "tool_call", + text: "tool call (completed): apps\\npackage.json", + title: "tool call", + status: "completed", + toolCallId: "tool-1", + tag: "tool_call_update", + }; + yield { type: "done", stopReason: "end_turn" }; + })(), + result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), + cancel: async () => {}, + }), + close: async () => {}, + }) as never, + }); + + const result = await execute({ + runId: "run-tool-call-coalesce", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir, + coalescePlaceholderToolUpdates: true, + }, + context: {}, + onLog: async (stream: "stdout" | "stderr", text: string) => { + logs.push({ stream, text }); + }, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(0); + const toolCallEvents = logs + .map((entry) => { + try { + return JSON.parse(entry.text) as Record; + } catch { + return null; + } + }) + .filter((parsed): parsed is Record => parsed?.type === "acpx.tool_call"); + // The two placeholder-title in-progress updates are dropped; the pending + // announcement, the resolved-title in-progress update, and the terminal + // completed update all survive. + expect(toolCallEvents.map((event) => [event.name, event.status])).toEqual([ + ["Bash", "pending"], + ["Running: ls apps", "in_progress"], + ["Bash", "completed"], + ]); + }); + it("captures per-run usage, cost deltas, and billing identity from the ACP runtime", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index aa1878a768..cf12a37efb 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -373,6 +373,10 @@ export interface AcpxEngineExecutorOptions { interface AcpxPreparedRuntime { acpxAgent: string; + // See the config parsing site: adapter-declared engine behavior knobs with + // behavior-preserving defaults. + summaryStrategy: "full" | "lastOutputSegment"; + coalescePlaceholderToolUpdates: boolean; mode: "persistent" | "oneshot"; cwd: string; // Host-only spawn cwd for the acpx runtime's host `spawn()` of the relay @@ -1604,6 +1608,14 @@ async function buildRuntime(input: { ); const acpxAgent = normalizeAgent(config); + // Engine behavior knobs set by the invoking adapter's acpx config builder + // (never by the engine itself): a verbose streaming backend opts into + // last-segment run summaries and placeholder tool-update coalescing here. + // The defaults preserve the engine's long-standing behavior, and the engine + // carries no knowledge of which adapters opt in. + const summaryStrategy: "full" | "lastOutputSegment" = + config.summaryStrategy === "lastOutputSegment" ? "lastOutputSegment" : "full"; + const coalescePlaceholderToolUpdates = config.coalescePlaceholderToolUpdates === true; const mode = normalizeMode(config); const permissionMode = normalizePermissionMode(config); const nonInteractivePermissions = normalizeNonInteractivePermissions(config); @@ -2158,6 +2170,8 @@ async function buildRuntime(input: { return { acpxAgent, + summaryStrategy, + coalescePlaceholderToolUpdates, mode, // Remote runner-backed → the in-sandbox workspace dir; local / runner-less // → the HOST cwd (`sessionCwd` resolves both). Every cwd-keyed session site @@ -2443,16 +2457,41 @@ async function emitAcpxLog(ctx: AdapterExecutionContext, payload: Record= 0; i -= 1) { + const text = (input.outputSegments[i] ?? "").trim(); + if (text) return text; + } + const fallback = (input.fallback ?? "").trim(); + return fallback; +} + // acpx substitutes a literal "tool call" title when an ACP tool_call_update // omits one, which would persist a generic name over the real one ("Terminal", // "Read", …) in the stored run log. Remember each call's real title so update -// lines keep the name durably. +// lines keep the name durably. Some ACP backends also stream partial tool +// arguments as one in-progress update per token under this placeholder; +// adapters that opt into coalescePlaceholderToolUpdates in their acpx config +// have those updates coalesced until a real title is available. const GENERIC_ACP_TOOL_TITLE = "tool call"; async function emitRuntimeEvent( ctx: AdapterExecutionContext, event: AcpRuntimeEvent, toolTitles?: Map, + coalescePlaceholderToolUpdates?: boolean, ) { if (event.type === "text_delta") { await emitAcpxLog(ctx, { @@ -2464,6 +2503,22 @@ async function emitRuntimeEvent( return; } if (event.type === "tool_call") { + // Coalesce token-by-token argument streaming for adapters that opt in via + // their acpx config: skip in-progress updates that still carry only the + // unresolved placeholder title. Backends that stream tool arguments + // otherwise emit tens of thousands of these per run, flooding the + // transcript and pinning the live activity indicator to a generic + // "tool call" instead of the real tool. The initial pending event, the + // resolved-title in-progress update, and the terminal + // completed/failed/cancelled update all still flow through. Adapters that + // do not opt in never have an event dropped. + if ( + coalescePlaceholderToolUpdates && + event.status === "in_progress" && + (event.title ?? "").trim() === GENERIC_ACP_TOOL_TITLE + ) { + return; + } const eventRecord = event as Record; const toolInput = eventRecord.input; let name = event.title ?? "acp_tool"; @@ -3817,7 +3872,19 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // controller and never rejects; it returns a `TurnCompletion`. The step // bodies below record the external result for the coordinator to reproduce. const runTurn = async (_ready: StartupReady): Promise => { + // Summary accumulation, per the adapter-declared strategy. "full" (the + // default) collects every text delta exactly as before. + // "lastOutputSegment" collects output text only (never thought stream), + // segmented on tool starts so multi-step narration is not glued into one + // auto-comment dump. const textParts: string[] = []; + const outputSegments: string[] = []; + let currentOutputChunk: string[] = []; + const flushOutputSegment = () => { + if (currentOutputChunk.length === 0) return; + outputSegments.push(currentOutputChunk.join("")); + currentOutputChunk = []; + }; let eventBreakdown: AcpRuntimeUsageBreakdown | null = null; let eventCostUsd: number | null = null; // The turn-local state the sequence steps share. `promptBuild` sets the @@ -3920,13 +3987,22 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { const turn = activeTurn as AcpRuntimeTurn; const toolTitles = new Map(); for await (const event of turn.events) { - if (event.type === "text_delta") textParts.push(event.text); + if (event.type === "text_delta") { + if (prepared.summaryStrategy === "full") { + textParts.push(event.text); + } else if (event.stream !== "thought") { + currentOutputChunk.push(event.text); + } + } else if (event.type === "tool_call" && event.status === "pending") { + flushOutputSegment(); + } if (event.type === "status" && event.tag === "usage_update") { eventBreakdown = event.breakdown ?? eventBreakdown; eventCostUsd = usdCostAmount(event.cost) ?? eventCostUsd; } - await emitRuntimeEvent(ctx, event, toolTitles); + await emitRuntimeEvent(ctx, event, toolTitles, prepared.coalescePlaceholderToolUpdates); } + flushOutputSegment(); return await turn.result; }; const stepTurnFinalize = async ( @@ -4007,7 +4083,13 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { ? { cumulativeCostUsd: turnUsage.cumulativeCostUsd } : {}), }, - summary: textParts.join("").trim() || terminalStopReason || terminal.status, + summary: + prepared.summaryStrategy === "lastOutputSegment" + ? buildAcpxRunSummary({ + outputSegments, + fallback: terminalStopReason || terminal.status, + }) + : textParts.join("").trim() || terminalStopReason || terminal.status, clearSession, }; // The turn phase finished. A completed, non-timed-out turn is `ok`; every diff --git a/ui/src/adapters/types.ts b/ui/src/adapters/types.ts index 74bd48e538..8bfa4d7c78 100644 --- a/ui/src/adapters/types.ts +++ b/ui/src/adapters/types.ts @@ -41,4 +41,26 @@ export interface UIAdapterModule extends TranscriptParserSource { label: string; ConfigFields: ComponentType; buildAdapterConfig: (values: CreateConfigValues) => Record; + /** + * Optional issue-chat transcript presentation hints. Shared rendering code + * resolves these through the registry and never branches on adapter + * identities, so external/plugin adapters can declare them too. Omitted + * fields fall back to the defaults every adapter has today. + */ + transcriptPresentation?: { + /** + * Renderable transcript entries kept in the issue-chat window (default + * 30). Verbose streaming backends emit hundreds of entries per heartbeat; + * trimming those mid-run drops already-rendered content off the front and + * the index shift can mangle retraction smoothing. + */ + maxVisibleEntries?: number; + /** + * Live-run reasoning rendering (default "ticker", the one-line rolling + * view). "scrollLog" renders the full reasoning in a scrollable box that + * auto-follows the newest line — for backends whose delta volume + * overwhelms the ticker. + */ + liveReasoningView?: "ticker" | "scrollLog"; + }; } diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 0cdf2b1c6d..c04430464d 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -44,6 +44,7 @@ import type { IssueWorkMode, } from "@paperclipai/shared"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; +import { findUIAdapter } from "../adapters/registry"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; import { useSecondTick } from "../hooks/useSecondTick"; import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from "../hooks/usePaperclipIssueRuntime"; @@ -988,6 +989,15 @@ function IssueChatChainOfThought({ const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null; const agentId = authorAgentId ?? runAgentId; const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined; + // Adapters whose backends overwhelm the one-line reasoning ticker declare + // a scrollable live reasoning view via their UI adapter module + // (transcriptPresentation.liveReasoningView); resolved through the registry + // so this component never branches on adapter identities. Every adapter + // without a declaration keeps the existing ticker rendering. + const adapterType = typeof custom.adapterType === "string" ? custom.adapterType : null; + const isVerboseStreamingBackend = + (adapterType ? findUIAdapter(adapterType)?.transcriptPresentation?.liveReasoningView : undefined) === + "scrollLog"; const isMessageRunning = message.role === "assistant" && message.status?.type === "running"; const myIndex = useMemo( @@ -1076,7 +1086,21 @@ function IssueChatChainOfThought({ {expanded && hasContent ? (
- {isActive ? ( + {isActive && isVerboseStreamingBackend ? ( + <> + {allReasoningText ? : null} + {toolParts.map((tool) => ( + + ))} + + ) : isActive ? ( <> {allReasoningText ? : null} {toolParts.length > 0 ? : null} @@ -1102,6 +1126,55 @@ function IssueChatChainOfThought({ ); } +// Live reasoning for verbose streaming backends: the one-line +// ticker cannot keep up with token-level delta volume, so show the full +// reasoning in a scrollable box that auto-follows the newest line unless the +// reader has scrolled up to review earlier thinking. All other adapters keep +// the ticker (IssueChatReasoningPart below), which is unchanged. +function IssueChatVerboseLiveReasoningPart({ text }: { text: string }) { + const lines = text.split("\n").filter((l) => l.trim()); + const scrollRef = useRef(null); + const pinnedToBottomRef = useRef(true); + useEffect(() => { + const node = scrollRef.current; + if (!node) return; + if (pinnedToBottomRef.current) { + node.scrollTop = node.scrollHeight; + } + }, [text]); + + if (lines.length <= 1) { + return ; + } + + return ( +
+
+ +
+
{ + const node = scrollRef.current; + if (!node) return; + pinnedToBottomRef.current = + node.scrollHeight - node.scrollTop - node.clientHeight < 24; + }} + className="min-w-0 flex-1 max-h-40 space-y-0.5 overflow-y-auto pr-1" + > + {lines.map((line, index) => ( +

+ {line} +

+ ))} +
+
+ ); +} + function IssueChatReasoningPart({ text }: { text: string }) { const lines = text.split("\n").filter((l) => l.trim()); const lastLine = lines[lines.length - 1] ?? text.slice(-200); diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index 0182b71e34..8e0ad50189 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -17,6 +17,7 @@ import type { } from "./issue-thread-interactions"; import type { IssueTimelineEvent } from "./issue-timeline-events"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; +import { registerUIAdapter, unregisterUIAdapter } from "../adapters/registry"; function createAgent(id: string, name: string): Agent { return { @@ -1083,6 +1084,86 @@ describe("buildIssueChatMessages", () => { })); }); + it("honors a wider transcript window declared by an adapter's UI module", () => { + // Capability path: an adapter (built-in or plugin) declares + // transcriptPresentation on its UI module; shared code resolves it via the + // registry with no adapter identities. The test registers a synthetic + // verbose adapter — the test right above pins the default 30-entry window + // for adapters that declare nothing. + registerUIAdapter({ + type: "verbose_test_local", + label: "Verbose Test", + parseStdoutLine: () => [], + ConfigFields: () => null, + buildAdapterConfig: () => ({}), + transcriptPresentation: { maxVisibleEntries: 400, liveReasoningView: "scrollLog" }, + }); + + try { + const isoAt = (baseMs: number, offsetSeconds: number) => + new Date(baseMs + offsetSeconds * 1000).toISOString(); + const baseMs = Date.parse("2026-04-06T12:00:00.000Z"); + // 90 renderable entries: over the default 30-entry window, under the + // declared 400-entry window, so nothing is trimmed. + const transcript = [ + ...Array.from({ length: 9 }, (_, index) => ({ + kind: "assistant" as const, + ts: isoAt(baseMs, index), + text: `Older update ${index + 1}`, + })), + { + kind: "tool_call" as const, + ts: isoAt(baseMs, 9), + name: "search", + toolUseId: "tool-keep", + input: { query: "issue chat virtualization" }, + }, + ...Array.from({ length: 79 }, (_, index) => ({ + kind: "assistant" as const, + ts: isoAt(baseMs, 10 + index), + text: `Recent update ${index + 1}`, + })), + { + kind: "tool_result" as const, + ts: isoAt(baseMs, 89), + toolUseId: "tool-keep", + content: "search completed", + isError: false, + }, + ]; + + const messages = buildIssueChatMessages({ + comments: [], + timelineEvents: [], + linkedRuns: [ + { + runId: "run-history-verbose", + status: "succeeded", + agentId: "agent-1", + agentName: "VerboseCoder", + adapterType: "verbose_test_local", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + startedAt: new Date("2026-04-06T12:00:00.000Z"), + finishedAt: new Date("2026-04-06T12:03:00.000Z"), + }, + ], + liveRuns: [], + transcriptsByRunId: new Map([["run-history-verbose", transcript]]), + hasOutputForRun: (runId) => runId === "run-history-verbose", + currentUserId: "user-1", + }); + + expect(messages).toHaveLength(1); + const textParts = messages[0]?.content + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) ?? []; + expect(textParts.join("\n")).toContain("Older update 1"); + expect(textParts.join("\n")).toContain("Recent update 79"); + } finally { + unregisterUIAdapter("verbose_test_local"); + } + }); + it("keeps the same assistant message id when a live run becomes a cancelled historical run", () => { const liveMessages = buildIssueChatMessages({ comments: [], diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index f1aec74fe0..8187c5a455 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -18,6 +18,7 @@ import { } from "./issue-thread-interactions"; import type { IssueTimelineEvent } from "./issue-timeline-events"; import { isLiveIssueRun } from "./liveIssueIds"; +import { findUIAdapter } from "../adapters/registry"; import { summarizeNotice, } from "./transcriptPresentation"; @@ -87,6 +88,19 @@ export interface IssueChatTranscriptEntry { const ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES = 30; +// Adapters whose backends stream verbosely can declare a wider window via +// their UI adapter module (transcriptPresentation.maxVisibleEntries); every +// adapter without a declaration keeps the long-standing 30-entry window. +// Resolved through the registry so shared code never branches on adapter +// identities. +function issueChatTranscriptMaxVisibleEntries(adapterType: string | null | undefined): number { + if (!adapterType) return ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES; + return ( + findUIAdapter(adapterType)?.transcriptPresentation?.maxVisibleEntries ?? + ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES + ); +} + type MessageWithOrder = { createdAtMs: number; order: number; @@ -789,7 +803,7 @@ function createHistoricalTranscriptMessage(args: { }) { const { run, transcript, hasOutput, agentMap } = args; const agentName = run.agentName ?? agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8); - const compactedTranscript = compactIssueChatTranscript(transcript); + const compactedTranscript = compactIssueChatTranscript(transcript, issueChatTranscriptMaxVisibleEntries(run.adapterType)); const { parts, notices, segments } = buildAssistantPartsFromTranscript(compactedTranscript); const waitingText = hasOutput ? "" : "Run finished"; const content = parts.length > 0 @@ -1006,7 +1020,7 @@ function createLiveRunMessage(args: { transcript: readonly IssueChatTranscriptEntry[]; }) { const { run, transcript } = args; - const compactedTranscript = compactIssueChatTranscript(transcript); + const compactedTranscript = compactIssueChatTranscript(transcript, issueChatTranscriptMaxVisibleEntries(run.adapterType)); const { parts, notices, segments } = buildAssistantPartsFromTranscript(compactedTranscript); const waitingText = run.status === "queued"