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:
parent
e826188e82
commit
1d0e826767
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue