From f628d54bc87335deb3c4e32345687d5aeb90e293 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 01:57:28 -0500 Subject: [PATCH] fix(hermes): project native completion and tool activity Co-Authored-By: Paperclip --- doc/plans/2026-10-06-hermes-native-runner.md | 43 ++++++++++++++++++- .../src/drivers/acpx/profile-installation.ts | 6 +++ packages/paperclip-runner/src/live/index.ts | 2 +- server/src/adapters/registry.test.ts | 37 +++++++++++++++- server/src/adapters/registry.ts | 15 +++++-- .../paperclip-control-plane-port.test.ts | 29 +++++++++++++ .../status-decision-committer.ts | 7 +++ .../src/vendor/paperclip-runner/live/index.ts | 1 + .../task-chat/transcript-adapter.test.ts | 41 +++++++++++++++++- .../task-chat/transcript-adapter.ts | 23 +++++++++- .../transcript/native-run-events.test.ts | 24 +++++++++++ .../transcript/native-run-events.ts | 9 ++++ 12 files changed, 225 insertions(+), 12 deletions(-) diff --git a/doc/plans/2026-10-06-hermes-native-runner.md b/doc/plans/2026-10-06-hermes-native-runner.md index 6a7df21b87..ad1cf49fc7 100644 --- a/doc/plans/2026-10-06-hermes-native-runner.md +++ b/doc/plans/2026-10-06-hermes-native-runner.md @@ -1671,8 +1671,9 @@ when it has a session grant. The independently checked skill bytes stay intact. The reviewed Mac and Linux closure pins change only for the bridge source. Fresh cloud materialization, current-head native checks and PR verification are required. No local Docker or Rust build is used. Shared configuration adoption, -routine completion and transcript UI patches remain unapplied outside the fixed -PR file set. Hermes remains pending the full release qualification matrix. +routine completion and transcript UI corrections are implementation work within +the authorized release scope. The review-size split below replaces the earlier +fixed-file-set interpretation. Hermes remains pending the full release matrix. ### 2026-10-08 active steering through the production transport @@ -1718,3 +1719,41 @@ CI; a Mac skip is not Linux evidence. Candidate closure pins replace only match them before any runtime is admitted. The prior Linux failure remains recorded. Both existing PR file sets remain unchanged, and no local Docker or Rust build is used. Full provider, product and Daytona qualification is pending. + +### 2026-10-09 shared verification and completion corrections + +The repository requires each PR to contain fewer than 100 changed files. It +does not require a frozen file list or extra approval for necessary shared fixes. +The stack now retains the original public setup and execution-account cache +tests in a small follow-up review. Their full contents match the pre-split +snapshot. The original Cursor setup tests remain in the parent. No assertion is +removed. The stack is #15434, #15435, #15436 and #15654. + +The test worker now makes only its private test-home directories writable +before removal. It skips symbolic links. A real worker-exit check removes a +read-only nested runtime bundle while leaving an outside link target unchanged. +All 30 focused runtime-context and working-copy tests pass locally. Fresh Linux +CI remains required; the earlier cleanup failure retains its verdict. + +The Daytona image identity now includes the three Hermes setup helpers. All 11 +image-contract tests pass, including a changed-source case for each helper. +The public setup suite passes five tests and the account-cache suite passes four. +These checks do not execute Docker or a model. + +Native status finalization now updates an originating routine firing in the +same status-decision transaction. The real-database conformance suite passes +34 tests, including completion replay and rollback with an unchanged firing. +Hermes environment checks now verify its pinned local installation and return +a pending-qualification warning. Remote checks admit Linux x64 and macOS arm64; +unsupported platforms and unavailable installations fail. The adapter suite +passes all 39 tests after adding the server's missing public probe export. +The initial failed export tests remain recorded. Live routine and configuration +journeys remain separate acceptance requirements. + +Shared transcript projection now preserves plain native results, uses actual +process arguments for command previews and renders serialized arguments without +JSON punctuation. Later arguments update the same tool row; overlapping tool +IDs remain separate. Native output preserves newlines and failure details. +Unrecognized structured results remain inspectable. All 166 transcript and +boundary checks pass locally, and UI token gates pass. These are presentation +contract checks; rendered browser acceptance remains required. diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts index d16a75f8b4..a7c891f7c2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts @@ -56,3 +56,9 @@ export async function probeAcpxCursorInstallation(model: string): Promise const installation = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("cursor", model)); await (await installation.openCommand()).close(); } + +/** Verify the pinned Hermes distribution and host sandbox without credentials. */ +export async function probeAcpxHermesInstallation(model: string): Promise { + const installation = await verifyHermesInstallation(resolveQualifiedAcpxProfile("hermes", model)); + await (await installation.openCommand()).close(); +} diff --git a/packages/paperclip-runner/src/live/index.ts b/packages/paperclip-runner/src/live/index.ts index 15737c6481..a8c6038807 100644 --- a/packages/paperclip-runner/src/live/index.ts +++ b/packages/paperclip-runner/src/live/index.ts @@ -7,6 +7,6 @@ export * from "./turn-stream.js"; export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation } from "../drivers/acpx/installation-integrity.js"; -export { probeAcpxCursorInstallation } from "../drivers/acpx/profile-installation.js"; +export { probeAcpxCursorInstallation, probeAcpxHermesInstallation } from "../drivers/acpx/profile-installation.js"; export { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "./bundled-remote-provider-pack.js"; diff --git a/server/src/adapters/registry.test.ts b/server/src/adapters/registry.test.ts index 572c1c9c47..1d32895460 100644 --- a/server/src/adapters/registry.test.ts +++ b/server/src/adapters/registry.test.ts @@ -4,13 +4,15 @@ import { listServerAdapters, requireServerAdapter } from "./registry.js"; import * as executionTarget from "@paperclipai/adapter-utils/execution-target"; import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js"; -const { probeInstallation, probeGrokInstallation } = vi.hoisted(() => ({ +const { probeInstallation, probeGrokInstallation, probeHermesInstallation } = vi.hoisted(() => ({ probeInstallation: vi.fn(), probeGrokInstallation: vi.fn(), + probeHermesInstallation: vi.fn(), })); vi.mock("@paperclipai/paperclip-runner/live", () => ({ probeAcpxClaudeInstallation: probeInstallation, probeAcpxGrokInstallation: probeGrokInstallation, + probeAcpxHermesInstallation: probeHermesInstallation, probeAcpxCursorInstallation: vi.fn(async () => undefined), })); @@ -96,8 +98,9 @@ describe("native ACPX environment checks", () => { beforeEach(() => { probeInstallation.mockReset().mockResolvedValue(undefined); probeGrokInstallation.mockReset().mockResolvedValue(undefined); + probeHermesInstallation.mockReset().mockResolvedValue(undefined); }); - afterEach(() => vi.restoreAllMocks()); + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); const context = { companyId: "company-test", @@ -132,6 +135,36 @@ describe("native ACPX environment checks", () => { expect(probeInstallation).not.toHaveBeenCalled(); }); + it.each([true, false])("checks the admitted Hermes runtime without claiming production qualification (%s)", async ready => { + vi.stubEnv("PAPERCLIP_RUNNER_ACPX_QUALIFICATION", JSON.stringify([{ agent: "hermes", model: "hermes-validation-fixture" }])); + if (!ready) probeHermesInstallation.mockRejectedValueOnce(new Error("Hermes runtime is unavailable; run paperclipai runtime setup hermes")); + const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ + ...context, config: { provider: "acpx", acpxAgent: "hermes", model: "hermes-validation-fixture" }, + }); + expect(result.status).toBe(ready ? "warn" : "fail"); + expect(result.checks[0]).toMatchObject({ code: ready ? "acpx_candidate_qualification_only" : "acpx_runtime_unavailable" }); + expect(probeHermesInstallation).toHaveBeenCalledWith("hermes-validation-fixture"); + expect(probeInstallation).not.toHaveBeenCalled(); + expect(probeGrokInstallation).not.toHaveBeenCalled(); + }); + + it.each([ + ["Linux\nx86_64\n", "warn"], ["Darwin\narm64\n", "warn"], + ["Darwin\nx86_64\n", "fail"], ["Linux\naarch64\n", "fail"], + ])("admits only supported Hermes remote platforms %j", async (stdout, status) => { + vi.stubEnv("PAPERCLIP_RUNNER_ACPX_QUALIFICATION", JSON.stringify([{ agent: "hermes", model: "hermes-validation-fixture" }])); + const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ + ...context, config: { provider: "acpx", acpxAgent: "hermes", model: "hermes-validation-fixture" }, + executionTarget: { + kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "test", + runner: { execute: vi.fn().mockResolvedValue({ exitCode: 0, timedOut: false, stdout }) }, + }, + }); + expect(result.status).toBe(status); + expect(result.checks[0].code).toBe(status === "warn" ? "acpx_remote_runtime_unverified" : "acpx_runtime_unavailable"); + expect(probeHermesInstallation).not.toHaveBeenCalled(); + }); + it("does not use the host platform to reject a remote environment", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 6727cd60f3..723fd4a0ae 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -420,7 +420,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { }; } try { - if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok" && profile.acpxAgent !== "cursor") throw new Error("Select Codex to use the native Codex runner."); + if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok" && profile.acpxAgent !== "cursor" && profile.acpxAgent !== "hermes") throw new Error("Select Codex to use the native Codex runner."); const target = context.executionTarget; if (target?.kind === "remote") { const probe = await runAdapterExecutionTargetShellCommand( @@ -429,7 +429,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { ); if (probe.timedOut || probe.exitCode !== 0) throw new Error("Could not verify the remote ACPX runner platform."); const [os, arch] = probe.stdout.trim().split(/\s+/); - if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) { + if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (!["grok", "hermes"].includes(profile.acpxAgent) && arch === "x86_64"))))) { throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux x64 or macOS architecture.`); } return { @@ -438,8 +438,15 @@ const paperclipRunnerAdapter: ServerAdapterModule = { message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }], }; } - const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation } = await import("../vendor/paperclip-runner/live/index.js"); - await (profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : profile.acpxAgent === "cursor" ? probeAcpxCursorInstallation : probeAcpxClaudeInstallation)(profile.model); + const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation, probeAcpxHermesInstallation } = await import("../vendor/paperclip-runner/live/index.js"); + await (profile.acpxAgent === "hermes" ? probeAcpxHermesInstallation : profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : profile.acpxAgent === "cursor" ? probeAcpxCursorInstallation : probeAcpxClaudeInstallation)(profile.model); + if (profile.acpxAgent === "hermes") { + return { + adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), + checks: [{ code: "acpx_candidate_qualification_only", level: "warn" as const, + message: "The pinned Hermes runtime and host sandbox are verified. This exact model is admitted for operator-controlled qualification only; model access and the selected connection are checked before execution. Production qualification remains pending." }], + }; + } return { adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date().toISOString(), checks: [{ code: "acpx_runtime_ready", level: "info" as const, message: `ACPX ${profile.acpxAgent} runtime is installed and verified. Model access is checked when it runs.` }], diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts index 129ffdcf23..0e98c3d6d5 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts @@ -21,6 +21,8 @@ import { issues, nativeRunFinalizations, nativeRunResults, + routines, + routineRuns, statusDecisionEffects, statusDecisions, workAssessments, @@ -290,6 +292,8 @@ describe("PaperclipControlPlanePort conformance", () => { await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(completionContracts); + await db.delete(routineRuns); + await db.delete(routines); await db.delete(issues); await db.delete(agents); await db.delete(companyMemberships); @@ -974,7 +978,18 @@ describe("PaperclipControlPlanePort conformance", () => { expect(response.decision.chosenSource).toBe("final_agent_message"); expect(stored.findIndex((row) => row.eventType === "run.result.accepted")) .toBeGreaterThan(stored.findIndex((row) => row.eventType === "item.completed")); + const routineId = randomUUID(); + const firingId = randomUUID(); + await db.insert(routines).values({ id: routineId, companyId: identity.companyId, + title: "Native completion routine", assigneeAgentId: identity.agentId }); + await db.insert(routineRuns).values({ id: firingId, companyId: identity.companyId, + routineId, source: "schedule", status: "issue_created", linkedIssueId: taskIssueId }); + await db.update(issues).set({ originKind: "routine_execution", originId: routineId, + originRunId: firingId }).where(eq(issues.id, taskIssueId)); await finalizeNativeRun({ db, runId: taskRunId, workspaceFinalizeStatus: "succeeded" }); + const [completedFiring] = await db.select().from(routineRuns).where(eq(routineRuns.id, firingId)); + expect(completedFiring).toMatchObject({ status: "completed", failureReason: null }); + expect(completedFiring.completedAt).not.toBeNull(); await expect(port.completeRun({ result: taskResult, terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, @@ -996,6 +1011,9 @@ describe("PaperclipControlPlanePort conformance", () => { await expect(db.select().from(issues).where(eq(issues.id, taskIssueId))).resolves.toEqual([ expect.objectContaining({ status: "done", statusVersion: 1 }), ]); + await expect(db.select().from(routineRuns).where(eq(routineRuns.id, firingId))).resolves.toEqual([ + expect.objectContaining({ status: "completed", completedAt: completedFiring.completedAt }), + ]); await expect(db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, taskRunId))).resolves.toHaveLength(1); await expect(db.select().from(workAssessments).where(eq(workAssessments.runId, taskRunId))).resolves.toHaveLength(1); await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, taskIssueId))).resolves.toHaveLength(1); @@ -2161,6 +2179,14 @@ describe("PaperclipControlPlanePort conformance", () => { backendKind: "mock", sourceInstanceId: runnerInstanceId, }); + const routineId = randomUUID(); + const firingId = randomUUID(); + await db.insert(routines).values({ id: routineId, companyId: identity.companyId, + title: `Atomic routine ${entry.failpoint}`, assigneeAgentId: identity.agentId }); + await db.insert(routineRuns).values({ id: firingId, companyId: identity.companyId, + routineId, source: "schedule", status: "issue_created", linkedIssueId: issueId }); + await db.update(issues).set({ originKind: "routine_execution", originId: routineId, + originRunId: firingId }).where(eq(issues.id, issueId)); await port.completeRun({ result: entry.result, terminal, callerResultId: `atomic-result-${entry.suffix}` }); await expect(finalizeNativeRun({ db, @@ -2171,6 +2197,9 @@ describe("PaperclipControlPlanePort conformance", () => { await expect(db.select().from(issues).where(eq(issues.id, issueId))).resolves.toEqual([ expect.objectContaining({ status: "in_progress", statusVersion: 0, lastStatusDecisionId: null }), ]); + await expect(db.select().from(routineRuns).where(eq(routineRuns.id, firingId))).resolves.toEqual([ + expect.objectContaining({ status: "issue_created", completedAt: null, failureReason: null }), + ]); await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, issueId))).resolves.toHaveLength(0); await expect(db.select().from(statusDecisionEffects).where(eq(statusDecisionEffects.issueId, issueId))).resolves.toHaveLength(0); await expect(db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId))).resolves.toHaveLength(0); diff --git a/server/src/services/native-runtime/status-decision-committer.ts b/server/src/services/native-runtime/status-decision-committer.ts index 6d60ac6143..3766d94e91 100644 --- a/server/src/services/native-runtime/status-decision-committer.ts +++ b/server/src/services/native-runtime/status-decision-committer.ts @@ -45,6 +45,7 @@ import { resolveExternalChatResponseWaitAuthorizationInTransaction, } from "./chat-attachment-reuse.js"; import { issueService } from "../issues.js"; +import { routineService } from "../routines.js"; import { issueThreadInteractionService } from "../issue-thread-interactions.js"; import { issueRecoveryActionService } from "../issue-recovery-actions.js"; import { buildIssueBlockersResolvedWakeIdempotencyKey } from "../issue-dependency-wakeups.js"; @@ -2037,6 +2038,12 @@ export async function commitNativeStatusDecision(input: { }); } + // Keep the firing projection atomic with the authoritative task status. + // Recovery and normal completion use the same status-decision transaction. + if (updated.originKind === "routine_execution") { + await routineService(tx as unknown as Db).syncRunStatusForIssue(input.issueId); + } + if (input.decision.statusAction === "done" && issue.status !== "done") { const issueSvc = issueService(tx as unknown as Db); const dependents = await issueSvc.listWakeableBlockedDependents( diff --git a/server/src/vendor/paperclip-runner/live/index.ts b/server/src/vendor/paperclip-runner/live/index.ts index a17ed6de1b..7de7c760d6 100644 --- a/server/src/vendor/paperclip-runner/live/index.ts +++ b/server/src/vendor/paperclip-runner/live/index.ts @@ -3,4 +3,5 @@ export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation, + probeAcpxHermesInstallation, } from "@paperclipai/paperclip-runner/live"; diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index 455055c04a..862b38bfb2 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -248,14 +248,14 @@ describe("omitProgressRepeatedByResponseAcrossSegments", () => { }); }); -function toolCall(name: string, input?: unknown): TranscriptEntry { +function toolCall(name: string, input?: unknown): Extract { return { kind: "tool_call", ts: TS, name, toolUseId: `tool-${name}`, input, - } as TranscriptEntry; + }; } describe("splitTranscriptAtAnchors", () => { @@ -366,6 +366,43 @@ describe("toolDisplayName", () => { describe("transcriptToTaskChatItems tool_call updates", () => { const opts = { runId: "run-1", running: false }; + it("renders serialized native command arguments as the invocation", () => { + const items = transcriptToTaskChatItems([ + toolCall("terminal", JSON.stringify({ cmd: "printf 'hello'", cwd: "/workspace" })), + { kind: "tool_result", ts: TS, toolUseId: "tool-terminal", content: "hello", isError: false }, + ], opts); + expect(items).toMatchObject([{ kind: "tool", target: "printf 'hello'", detail: "hello", status: "completed" }]); + }); + + it("updates a missing invocation without merging overlapping same-name calls", () => { + const items = transcriptToTaskChatItems([ + toolCall("terminal"), + { ...toolCall("terminal", { command: "echo second" }), toolUseId: "second-call" }, + toolCall("terminal", JSON.stringify({ command: "echo first" })), + { kind: "tool_result", ts: TS, toolUseId: "second-call", content: "second", isError: false }, + { kind: "tool_result", ts: TS, toolUseId: "tool-terminal", content: "first", isError: false }, + ], opts); + expect(items).toMatchObject([ + { kind: "tool", target: "echo first", detail: "first", status: "completed" }, + { kind: "tool", target: "echo second", detail: "second", status: "completed" }, + ]); + }); + + it("shows native output and failures while retaining unknown structured details", () => { + const metadata = JSON.stringify({ disposition: "applied", document: { key: "plan" } }); + const items = transcriptToTaskChatItems([ + toolCall("terminal", { command: "run checks" }), + { kind: "tool_result", ts: TS, toolUseId: "tool-terminal", isError: true, + content: JSON.stringify({ stdout: "Checking\nTwo checks failed", stderr: "Missing input", exit_code: 2 }) }, + toolCall("write_document", { key: "plan" }), + { kind: "tool_result", ts: TS, toolUseId: "tool-write_document", content: metadata, isError: false }, + ], opts); + expect(items).toMatchObject([ + { kind: "tool", status: "failed", detail: "Checking\nTwo checks failed\nMissing input\nExit code: 2" }, + { kind: "tool", status: "completed", detail: metadata }, + ]); + }); + function update(toolUseId: string, status: string): TranscriptEntry { // Mirrors what a persisted acpx tool_call_update line parses to: the // literal placeholder name plus a synthesized { text, status } input. diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 5f84863768..fb8a3d81c3 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -139,6 +139,7 @@ const TARGET_KEYS = [ "path", "notebook_path", "command", + "cmd", "pattern", "query", "url", @@ -162,6 +163,12 @@ function clip(text: string, max: number): string { */ export function summarizeToolInput(input: unknown): string | undefined { if (input == null) return undefined; + if (typeof input === "string") { + try { + const parsed: unknown = JSON.parse(input); + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) input = parsed; + } catch { /* Ordinary text remains a valid tool input. */ } + } if (typeof input === "string") return input.trim() ? clip(input, TARGET_MAX) : undefined; if (typeof input !== "object") return clip(String(input), TARGET_MAX); @@ -555,7 +562,19 @@ function mergeProviderActivityItem( /** Result content → the expandable mono detail block (clipped, trimmed). */ function formatToolResultDetail(content: unknown): string | undefined { if (content == null) return undefined; - const text = typeof content === "string" ? content : String(content); + let text = typeof content === "string" ? content : String(content); + try { + const result: unknown = typeof content === "string" ? JSON.parse(content) : content; + const output = objectRecord(result); + const parts = [output.output ?? output.stdout ?? output.content, output.stderr, output.error] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0); + if (parts.length > 0) { + text = [...new Set(parts)].join("\n"); + if (typeof output.exit_code === "number" && output.exit_code !== 0) { + text += `\nExit code: ${output.exit_code}`; + } + } + } catch { /* Preserve plain output and unrecognized structured results. */ } const trimmed = text.trim(); if (!trimmed) return undefined; return trimmed.length > DETAIL_MAX @@ -733,6 +752,8 @@ export function transcriptToTaskChatItems( existing.target = clip(entry.name!, TARGET_MAX); } } + const incomingTarget = summarizeToolInput(entry.input); + if (incomingTarget) existing.target = incomingTarget; lastToolIndex = existingIndex!; } else { items.push({ diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 7fb01b5b37..7c9f34ff3d 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -118,6 +118,30 @@ describe("provider notice presentation", () => { }); describe("nativeRunEventsToTranscript", () => { + it.each(["command", "cmd"])("uses native %s arguments for process previews and preserves plain results", (key) => { + const input = { [key]: "printf 'native output'", cwd: "/workspace" }; + const transcript = nativeRunEventsToTranscript([ + event(1, "tool.execution.started", { + schema: "paperclip.tool.execution.v1", executionId: "hermes-terminal", + transport: "process", operation: "execute", name: "terminal", status: "running", + }), + itemEvent(2, "item.started", "hermes-terminal", { + item: { type: "tool_use", id: "hermes-terminal", name: "terminal", input: JSON.stringify(input) }, + }), + event(3, "tool.execution.completed", { + schema: "paperclip.tool.execution.v1", executionId: "hermes-terminal", + transport: "process", operation: "execute", name: "terminal", status: "completed", + }), + itemEvent(4, "item.completed", "hermes-terminal", { + item: { type: "tool_result", tool_use_id: "hermes-terminal", result: "native output\nnext line" }, + }), + ]); + expect(transcript).toMatchObject([ + { kind: "tool_call", toolUseId: "hermes-terminal", name: "Bash", input }, + { kind: "tool_result", toolUseId: "hermes-terminal", content: "native output\nnext line", isError: false }, + ]); + }); + describe("accepted response-wake authority", () => { function fixture() { const result = { diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index 85f5426a16..9eeedd3c45 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -547,6 +547,7 @@ function nativeToolItemDetails( function serializedNativeToolResult(item: NativeToolItemDetails): string { if (item.result === undefined) return ""; + if (typeof item.result === "string") return item.result; try { return JSON.stringify(item.result) ?? ""; } catch { @@ -562,6 +563,14 @@ function toolPresentation( const operation = text(payload.operation); const reportedName = text(payload.name) ?? item?.name ?? null; if (transport === "process") { + let input = item?.input; + if (typeof input === "string") { + try { input = JSON.parse(input); } catch { /* Keep the reported invocation fallback. */ } + } + const argumentsRecord = record(input); + if (text(argumentsRecord?.command) || text(argumentsRecord?.cmd)) { + return { name: "Bash", input }; + } return { name: "Bash", input: reportedName ? { command: reportedName } : { operation: operation ?? "execute" },