feat(acpx/ui): adapter-declared capabilities for verbose streaming backends (no behavior change by default) (#11761)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Local agent adapters stream their work through the shared acpx
engine, which every ACP adapter (`claude_local`, `codex_local`,
`gemini_local`, custom ACP) runs on
> - Verbose streaming backends break two shared-engine behaviors: the
auto-posted run summary concatenates every text delta including the
thought stream (a long multi-tool run once auto-posted a ~50k character
monologue as an issue comment), and token-by-token tool-argument
streaming floods the run log with tens of thousands of
placeholder-titled in-progress events per run
> - These fixes were developed inside the `kimi_local` adapter PR, where
Kimi Code's streaming volume (~16,000 text deltas per run vs ~290 for a
comparable Claude run) surfaced both problems
> - Changing this behavior for all adapters at once is a fleet-wide
risk, and hardcoding adapter identities in shared code does not scale to
many adapters (or work at all for externally-shipped plugin adapters) —
so the behaviors become invocation-config parameters that an adapter's
own acpx config builder sets, with defaults preserving today's behavior
byte-for-byte
> - The benefit is that the machinery lands fully tested with zero
behavior change for existing adapters — pin tests prove it — the engine
carries no adapter identities, and any adapter (including `custom_acp`
configs for external backends) opts in declaratively

## Linked Issues or Issue Description

- Refs #9967 — extracted from the `kimi_local` adapter PR and
restructured to be inert by default; the commit preserves the original
author's (@hawikk) authorship.

**Current behavior**

When an acpx-engine run ends without the agent leaving a comment, the
auto-posted summary is every streamed text delta concatenated, thought
stream included. Backends that stream tool arguments emit tens of
thousands of placeholder-titled `in_progress` tool events into the
stored run log, pinning the live activity indicator to a generic "tool
call". There is no mechanism for an adapter to vary either behavior, and
shared code must never branch on adapter identities.

**Proposed behavior**

Two engine invocation-config parameters, read with behavior-preserving
defaults: `summaryStrategy` (`"full"` = existing concatenation, the
default; `"lastOutputSegment"` = segment output at tool starts, exclude
thought stream, post the last non-empty segment) and
`coalescePlaceholderToolUpdates` (`false` = never drop an event, the
default; `true` = coalesce placeholder-titled in-progress updates). An
adapter opts in from its own acpx config builder — the engine has no
per-adapter knowledge, no adapter identity appears anywhere in shared
code, and `custom_acp` agent configs can set the same knobs for external
verbose backends.

**Reason and benefit**

Existing adapters are provably unaffected — new pin tests assert the
default path's summary and tool-event output byte-for-byte, so any
future change that alters behavior for claude/codex/gemini/custom fails
the suite. The verbose-backend handling still lands fully tested,
activated declaratively by the adapter that needs it (the `kimi_local`
adapter PR sets both knobs in its config builder).

## What Changed

- `packages/adapter-utils/src/acpx-engine/execute.ts`: the run
preparation parses `summaryStrategy` and
`coalescePlaceholderToolUpdates` from the invocation config (validated,
defaulted); summary accumulation and `emitRuntimeEvent` branch on the
prepared values. The default path is the pre-existing code
(`textParts.join("")`, no event filtering). `buildAcpxRunSummary` is the
exported last-segment strategy.
- `packages/adapter-utils/src/acpx-engine/execute.test.ts`: a pin test
asserting the default path's exact summary (thought stream included) and
full tool-event stream (placeholder-titled in-progress updates present,
names restored); opt-in tests for each knob; a `buildAcpxRunSummary`
unit test.

- `ui/src/adapters/types.ts`: `UIAdapterModule` gains an optional
`transcriptPresentation` capability — `maxVisibleEntries` (issue-chat
transcript window, default 30) and `liveReasoningView` (`"ticker"`
default; `"scrollLog"` renders live reasoning in a scrollable
auto-following box with one entry per tool call).
- `ui/src/lib/issue-chat-messages.ts` and
`ui/src/components/IssueChatThread.tsx`: shared code resolves the hints
via `findUIAdapter(adapterType)` with today's defaults as fallback — no
adapter identities anywhere. The `scrollLog` rendering component ships
here but is unreachable until an adapter declares it.
- `ui/src/lib/issue-chat-messages.test.ts`: a capability test registers
a synthetic verbose adapter and asserts the wider window; the
pre-existing test keeps pinning the default 30-entry window.

No adapter declares any of this in this PR — every adapter renders and
summarizes exactly as before, and there is no per-adapter data anywhere.
The `kimi_local` adapter PR (#9967, stacked on this branch) is the first
consumer: it declares `transcriptPresentation` in its own UI module and
sets the engine knobs in its own acpx config builder.

## Verification

- Engine suite: 130/130 pass (126 existing + 4 new); chat suites
(`issue-chat-messages`, `IssueChatThread`, `RunChatSurface`): all pass
including the new synthetic-adapter capability test — 237 tests across
the touched surfaces
- The pin tests are the regression guard: the engine test encodes
today's summary text and tool-event sequence for a default-config run,
and the existing 30-entry-window test pins the default transcript
window, so "nothing changed for Claude/Codex users" is an executable
assertion, not a review judgment
- `pnpm --filter @paperclipai/adapter-utils --filter @paperclipai/ui
typecheck`: clean

## Risks

- Low: with no adapter setting the knobs, every code path taken in
production is the existing one. The only behavioral surface is additive
(an unused strategy and an unused filter), exercised by tests.
- The knobs are ordinary invocation-config keys, so a `custom_acp` agent
config can also set them — intended: an external verbose backend gets
the same handling without code changes. Both knobs only affect that
agent's own run summaries and run-log verbosity.

## Model Used

- Original implementation authored in #9967 by @hawikk (models
documented there: Moonshot AI Kimi K3 Coding via Kimi Code CLI 0.27.0,
OpenAI GPT-5 Codex, Anthropic Claude Opus 4.8). The commit preserves
that authorship.
- Extraction, restructuring into config-declared parameters, pin tests,
and verification: Anthropic, **Claude Fable 5** (`claude-fable-5`) via
Claude Code, with repository, shell, and Git tooling.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Hawik <davapa@gmail.com>
This commit is contained in:
Devin Foley 2026-08-20 10:37:58 -07:00 committed by GitHub
parent e826188e82
commit 1d0e826767
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 578 additions and 7 deletions

View File

@ -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<string, unknown>;
} catch {
return null;
}
})
.filter((parsed): parsed is Record<string, unknown> => 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<string, unknown>;
} catch {
return null;
}
})
.filter((parsed): parsed is Record<string, unknown> => 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");

View File

@ -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<string,
await ctx.onLog("stdout", `${JSON.stringify(payload)}\n`);
}
/**
* Build the short run summary that Paperclip may auto-post as an issue comment
* when the agent leaves no comment of its own. Used only for agents whose
* traits opt into the "lastOutputSegment" summary strategy.
*
* Prefer the last non-empty *output* segment after a tool call. Intermediate
* "let me check…" narration between tools must not become a 50k-char dump.
* Thought-stream text is never included (callers must not push it into segments).
*/
export function buildAcpxRunSummary(input: {
outputSegments: string[];
fallback?: string | null;
}): string {
for (let i = input.outputSegments.length - 1; i >= 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<string, string>,
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<string, unknown>;
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<TurnCompletion> => {
// 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<string, string>();
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

View File

@ -41,4 +41,26 @@ export interface UIAdapterModule extends TranscriptParserSource {
label: string;
ConfigFields: ComponentType<AdapterConfigFieldsProps>;
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
/**
* 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";
};
}

View File

@ -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({
</button>
{expanded && hasContent ? (
<div className="space-y-1 py-1">
{isActive ? (
{isActive && isVerboseStreamingBackend ? (
<>
{allReasoningText ? <IssueChatVerboseLiveReasoningPart text={allReasoningText} /> : null}
{toolParts.map((tool) => (
<IssueChatToolPart
key={tool.toolCallId}
toolName={tool.toolName}
args={tool.args}
argsText={tool.argsText}
result={tool.result}
isError={false}
/>
))}
</>
) : isActive ? (
<>
{allReasoningText ? <IssueChatReasoningPart text={allReasoningText} /> : null}
{toolParts.length > 0 ? <IssueChatRollingToolPart toolParts={toolParts} /> : 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<HTMLDivElement>(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 <IssueChatReasoningPart text={text} />;
}
return (
<div className="flex gap-2 px-1">
<div className="flex flex-col items-center pt-0.5">
<Brain className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
</div>
<div
ref={scrollRef}
onScroll={() => {
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) => (
<p
key={index}
className="whitespace-pre-wrap break-words text-(length:--text-compact) italic leading-5 text-muted-foreground/70"
>
{line}
</p>
))}
</div>
</div>
);
}
function IssueChatReasoningPart({ text }: { text: string }) {
const lines = text.split("\n").filter((l) => l.trim());
const lastLine = lines[lines.length - 1] ?? text.slice(-200);

View File

@ -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: [],

View File

@ -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"