From 334db53750ac44910e5487dcafe84b4128beb88d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 05:19:40 -0500 Subject: [PATCH] fix(runner): retain unlabeled prefixes and complete tool JSON Retain the published message identity when a provider begins labeling deltas. Preserve the original full-prefix oracle, separate subsequent message boundaries, and keep unknown JSON result fields inspectable. Record native, server and browser proof and the pending release gates. Co-Authored-By: Paperclip --- doc/plans/2026-10-06-hermes-native-runner.md | 64 +++++++++++- packages/paperclip-runner/docs/hermes.md | 5 +- .../runner-core/src/acpx_provider_state.rs | 33 ++++++- .../crates/runner-core/src/provider_events.rs | 5 +- .../tests/acpx_event_projection.rs | 13 +-- .../runner-core/tests/acpx_provider_state.rs | 97 +++++++++++++++++-- .../tests/local_integrity_boundary_golden.rs | 2 +- .../task-chat/transcript-adapter.test.ts | 22 +++++ .../task-chat/transcript-adapter.ts | 12 ++- .../transcript/native-run-events.test.ts | 18 ++++ 10 files changed, 239 insertions(+), 32 deletions(-) diff --git a/doc/plans/2026-10-06-hermes-native-runner.md b/doc/plans/2026-10-06-hermes-native-runner.md index 12f36913a8..94ff2bf9ab 100644 --- a/doc/plans/2026-10-06-hermes-native-runner.md +++ b/doc/plans/2026-10-06-hermes-native-runner.md @@ -1,8 +1,8 @@ # Hermes native runner implementation -Status (updated 2026-10-07): implementation candidate; **not qualified**. -Current branch: `codex/hermes-qualification`; stacked on -`codex/hermes-native-runner` and `codex/hermes-routines`. +Status (updated 2026-10-09): implementation candidate; **not qualified**. +Current branch: `codex/hermes-release-qualification`; stacked on +`codex/hermes-qualification`, `codex/hermes-native-runner` and `codex/hermes-routines`. ## Accepted outcome @@ -20,9 +20,9 @@ Keep the existing Hermes local/gateway adapters compatible. - [x] Managed memory/skills, per-turn lifecycle and real routine-service binding. - [x] UI/configuration/contracts, documentation and package distribution. - [x] Focused TS/Python tests and production-path macOS execution with a deterministic model server. -- [x] Complete final Rust/regression checks and resolve or classify failures. +- [ ] Complete final Rust/regression checks and resolve or classify failures on the current source. - [ ] Browser acceptance, Linux/Daytona execution and connection-method qualification. -- [x] Reviewable draft PR stack, green CI and fresh Greptile 5/5 on the implementation heads. +- [ ] Reviewable draft PR stack, green CI and fresh Greptile 5/5 on the current implementation heads. - [x] Complete the final local aggregate test invocation and classify its failures. - [ ] Complete live release qualification. @@ -1884,3 +1884,57 @@ After terminal server results and empty open-file checks, only the four owned browser scratch roots are removed. Logs, screenshots, failed attempts and required runtime assets remain available. No local Docker or Rust build runs, and no paid inference or credential transfer occurs in these browser checks. + +### 2026-10-09 prefix adoption and complete JSON tool output + +Native fixture run `37909868256` passes on Mac arm64 and Linux amd64 at +`1af3bd2f102f74a3431b801950f92765813fe008`. It runs the production Hermes +path against a deterministic loopback model without credentials. The same +source fails the broader Rust suite's existing idless-prefix oracle. The +reducer now retains the first normalized item identity while adopting native +IDs for boundary detection. The original full-prefix text assertion remains +unchanged. Additional assertions require prefix/continuation/final identity +agreement and separate identity for a subsequent native message. Fresh cloud +Rust and native execution are required for this changed source. + +The expandable tool detail now unwraps only the complete command-result +shape. Arbitrary JSON, including `content` and extra fields, remains intact. +Regression coverage includes a JSON string supplied through native command +`output` and distinct output streams in a known command wrapper. All 173 +transcript/adapter/golden UI assertions pass. UI TypeScript and token gates +pass. This does not replace fresh rendered native browser acceptance. + +The runtime-skills database teardown drains background executions before +truncation instead of assuming a terminal row or a fixed delay means writes +have stopped. Two route suites load their cold graph in 30-second setup hooks +after mock reset; request deadlines and original assertions remain unchanged. +All 40 focused server tests, including real PostgreSQL, and server TypeScript +pass. Earlier CI failures retain their verdict. All four PRs remain draft; +paid connection, subscription, credential lifecycle and actual Daytona proof +remain required. + +### 2026-10-09 company-scoped canonical agent recovery + +An authorized UUID lookup can return an agent from another accessible company. +The canonical cache now receives that agent only under its actual company, +and the redirect uses that company's explicit prefix. Route and selection +synchronization yield to an explicit organization switch. Company page memory +waits for the canonical alias instead of retaining an unverified agent UUID; +legacy remembered UUID paths fall back to the company dashboard. + +A real-app browser fixture opens company A's agent UUID under company B's +prefix, follows the canonical redirect to A, switches back through the actual +organization menu, and opens B's same-named agent. While B's authorized alias +response is held, A's sentinel title must be absent. The released response +must show B's title, selection and route. All ten browser cases pass, including +the original nine exact-retry cases. The company-isolation and retry-denial +screenshots are visually inspected. All 195 focused UI assertions, UI +TypeScript and token gates pass. Inference is scripted; these checks use no +model credentials and do not qualify paid Hermes behavior. + +Earlier failed browser attempts remain recorded. One server-start attempt +fails because macOS's 32-segment shared-memory limit is reached. Only +unattached 56-byte segments from this task's exited PostgreSQL processes are +released. After closed-process and empty open-file checks, disposable test +homes and caches are removed; logs, screenshots and required runtime assets +remain. No local Docker or Rust build runs. diff --git a/packages/paperclip-runner/docs/hermes.md b/packages/paperclip-runner/docs/hermes.md index 1ddb542609..17952ded48 100644 --- a/packages/paperclip-runner/docs/hermes.md +++ b/packages/paperclip-runner/docs/hermes.md @@ -108,7 +108,10 @@ extend the task's execution deadline. - Assistant message identity v2 includes the provider message and provider turn. A final snapshot replaces only its own deltas, retaining preceding commentary and steered messages. Providers without message IDs retain the original - identity; existing PRP v1 events replay unchanged. This is an identity change + identity. If a provider first supplies an ID after an unlabelled prefix, + that prefix and the continuation retain one item identity and one final + snapshot. A later distinct native message gets its own item. Existing PRP v1 + events replay unchanged. This is an identity change within the current event contract, not a new wire field or transcript rewrite. - Native questions publish a 65,536-character limit for text and custom answers. The form, canonical response validation and bridge count UTF-16 diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs index 19f0c2aaa7..c0d6422fb3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs @@ -72,7 +72,7 @@ pub enum AcpxProviderStateEvent { SemanticResult(AcpxSemanticResult), AssistantMessage { turn_id: String, - message_id: Option, + provider_item_id: Option, text: String, }, TurnTerminal { @@ -110,6 +110,7 @@ pub struct AcpxProviderState { plan_revision: u64, assistant_text: String, assistant_message_id: Option, + assistant_item_id: Option, pi_assistant_message: Option, pi_assistant_message_ids: BTreeSet, pending_tools: BTreeMap, @@ -129,6 +130,7 @@ impl AcpxProviderState { plan_revision: 0, assistant_text: String::new(), assistant_message_id: None, + assistant_item_id: None, pi_assistant_message: None, pi_assistant_message_ids: BTreeSet::new(), pending_tools: BTreeMap::new(), @@ -223,6 +225,7 @@ impl AcpxProviderState { self.plan_revision = 0; self.assistant_text.clear(); self.assistant_message_id = None; + self.assistant_item_id = None; self.pi_assistant_message = None; self.pi_assistant_message_ids.clear(); self.semantic_result = None; @@ -389,13 +392,14 @@ impl AcpxProviderState { if status == AcpxTurnStatus::Completed && !self.assistant_text.is_empty() { events.push(AcpxProviderStateEvent::AssistantMessage { turn_id: turn_id.clone(), - message_id: self.assistant_message_id.take(), + provider_item_id: self.assistant_item_id.take(), text: std::mem::take(&mut self.assistant_text), }); } else { self.assistant_text.clear(); } self.assistant_message_id = None; + self.assistant_item_id = None; self.pi_assistant_message = None; self.pi_assistant_message_ids.clear(); events.push(AcpxProviderStateEvent::TurnTerminal { @@ -529,7 +533,7 @@ impl AcpxProviderState { let starts_new_message = provider_message_id.as_ref().is_some_and(|message_id| { self.assistant_message_id .as_ref() - .is_none_or(|current| current != message_id) + .is_some_and(|current| current != message_id) }); let raw_text = payload .get("text") @@ -594,6 +598,7 @@ impl AcpxProviderState { // the task UI and can promote intermediate prose as final output. if starts_new_message || native_boundary_clears { self.assistant_text.clear(); + self.assistant_item_id = None; } if self .assistant_text @@ -662,7 +667,7 @@ impl AcpxProviderState { .as_deref() .expect("a decoded runtime event has a turn binding"); let fallback_item_id = format!("acpx-event-{}", event.sequence); - let events = normalize_acpx_runtime_event( + let mut events = normalize_acpx_runtime_event( kind, &payload, tool_operation, @@ -670,6 +675,26 @@ impl AcpxProviderState { turn_id, self.provider_requests, ); + if kind == AcpxRuntimeEventKind::TextDelta + && payload.get("piMessageHistory").and_then(Value::as_bool) != Some(true) + { + // A provider can begin identifying a message after its first delta. + // Adopt that native ID for boundary detection, but retain the item's + // already-published identity so its prefix and final snapshot agree. + for activity in &mut events { + let item = activity + .payload + .as_object_mut() + .expect("a normalized ACPX text delta is an object"); + let item_id = item + .get("itemId") + .and_then(Value::as_str) + .expect("a normalized ACPX text delta has an item identity") + .to_owned(); + let retained_id = self.assistant_item_id.get_or_insert(item_id); + item.insert("itemId".to_owned(), Value::String(retained_id.clone())); + } + } if let Some(revision) = next_plan_revision { self.plan_revision = revision; } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs index 5e549d1ca1..62f19c0d39 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -378,13 +378,10 @@ pub fn project_acpx_state_event( } AcpxProviderStateEvent::AssistantMessage { turn_id, - message_id, + provider_item_id, text, } => { require_projected_turn(context, turn_id)?; - let provider_item_id = message_id - .as_deref() - .map(|message_id| acpx_message_item_id(message_id, turn_id, "assistant-message")); one( "item.completed", EventPriority::P1, diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_projection.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_projection.rs index 59cecd28fc..eecbed3830 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_projection.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_projection.rs @@ -151,7 +151,7 @@ fn keeps_durable_correlation_separate_from_the_active_provider_turn() { &context, &AcpxProviderStateEvent::AssistantMessage { turn_id: "provider-turn-1".to_owned(), - message_id: None, + provider_item_id: None, text: "Done".to_owned(), }, ) @@ -339,7 +339,7 @@ fn projects_assistant_terminal_and_diagnostic_events_fail_closed() { let assistant = project(AcpxProviderStateEvent::AssistantMessage { turn_id: "turn-1".to_owned(), - message_id: Some("opaque-provider-message".to_owned()), + provider_item_id: Some("opaque-provider-message".to_owned()), text: "Done".to_owned(), }); assert_eq!(assistant[0].event_type, "item.completed"); @@ -629,7 +629,7 @@ fn runtime_request_projection_preserves_durable_identity_boundaries() { fn recovery_preserves_the_preceding_provider_turn_answer() { let first = project(AcpxProviderStateEvent::AssistantMessage { turn_id: "turn-1".to_owned(), - message_id: None, + provider_item_id: None, text: "Useful answer".to_owned(), }); let mut recovery = context(); @@ -638,7 +638,7 @@ fn recovery_preserves_the_preceding_provider_turn_answer() { &recovery, &AcpxProviderStateEvent::AssistantMessage { turn_id: "recovery-turn".to_owned(), - message_id: None, + provider_item_id: None, text: "Recovery update".to_owned(), }, ) @@ -673,6 +673,7 @@ fn assistant_message_identity_matches_its_snapshot_and_is_scoped_to_the_provider 1, ) .remove(0); + let provider_item_id = activity.payload["itemId"].as_str().unwrap().to_owned(); let streamed = project(AcpxProviderStateEvent::Activity(activity.clone())); assert_eq!( streamed, @@ -680,7 +681,7 @@ fn assistant_message_identity_matches_its_snapshot_and_is_scoped_to_the_provider ); let final_message = project(AcpxProviderStateEvent::AssistantMessage { turn_id: "turn-1".to_owned(), - message_id: message_id.map(str::to_owned), + provider_item_id: Some(provider_item_id.clone()), text: "Done".to_owned(), }); assert_eq!( @@ -697,7 +698,7 @@ fn assistant_message_identity_matches_its_snapshot_and_is_scoped_to_the_provider &recovery, &AcpxProviderStateEvent::AssistantMessage { turn_id: "recovery-turn".to_owned(), - message_id: message_id.map(str::to_owned), + provider_item_id: Some(provider_item_id.clone()), text: "Recovered".to_owned(), }, ) diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_state.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_state.rs index c95d6d9e2a..ad9165b68c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_state.rs @@ -70,8 +70,9 @@ fn accumulates_assistant_text_preserves_reasoning_and_flushes_before_terminal() && event.payload["text"] == "Checking the result." )); + let mut assistant_item_id = None; for (sequence, text) in [(3, "Hello "), (4, "world")] { - state + let emitted = state .accept_event(&event( sequence, GeneratedAcpxSidecarEventType::RuntimeEvent, @@ -79,6 +80,14 @@ fn accumulates_assistant_text_preserves_reasoning_and_flushes_before_terminal() json!({"type":"text_delta","text":text}), )) .unwrap(); + let AcpxProviderStateEvent::Activity(activity) = &emitted[0] else { + panic!("an assistant delta must emit progress"); + }; + let item_id = activity.payload["itemId"].as_str().unwrap().to_owned(); + if let Some(retained) = &assistant_item_id { + assert_eq!(retained, &item_id); + } + assistant_item_id = Some(item_id); } let terminal = state .accept_event(&event( @@ -90,8 +99,8 @@ fn accumulates_assistant_text_preserves_reasoning_and_flushes_before_terminal() .unwrap(); assert!(matches!( &terminal[0], - AcpxProviderStateEvent::AssistantMessage { turn_id, message_id, text } - if turn_id == "turn-1" && message_id.is_none() && text == "Hello world" + AcpxProviderStateEvent::AssistantMessage { turn_id, provider_item_id, text } + if turn_id == "turn-1" && provider_item_id == &assistant_item_id && text == "Hello world" )); assert!(matches!( &terminal[1], @@ -183,8 +192,8 @@ fn promotes_only_the_latest_provider_message_as_the_terminal_reply() { .unwrap(); assert!(matches!( &terminal[0], - AcpxProviderStateEvent::AssistantMessage { message_id, text, .. } - if message_id.as_deref() == Some("message-2") + AcpxProviderStateEvent::AssistantMessage { provider_item_id, text, .. } + if provider_item_id.as_deref() == Some("message-2") && text == "Second paragraph.\n\nStill final." )); let projected_final = project_acpx_state_event(&context, &terminal[0]).unwrap(); @@ -227,7 +236,7 @@ fn an_unlabelled_continuation_keeps_the_current_message_identity() { project_acpx_state_event(&context, &emitted[0]).unwrap()[0].payload["itemId"].clone(), ); } - assert_ne!(ids[0], ids[1]); + assert_eq!(ids[0], ids[1]); assert_eq!(ids[1], ids[2]); let terminal = state .accept_event(&event( @@ -239,7 +248,62 @@ fn an_unlabelled_continuation_keeps_the_current_message_identity() { .unwrap(); let final_message = project_acpx_state_event(&context, &terminal[0]).unwrap(); assert_eq!(final_message[0].payload["itemId"], ids[2]); - assert_eq!(final_message[0].payload["text"], "Hello world"); + assert_eq!( + final_message[0].payload["text"], + "Unlabelled progress.Hello world" + ); +} + +#[test] +fn an_identified_message_after_an_adopted_prefix_gets_its_own_item() { + let context = AcpxEventProjectionContext { + run_id: "run-1".to_owned(), + normalized_session_id: "session-1".to_owned(), + turn_id: "turn-1".to_owned(), + provider_turn_id: None, + item_id: "item-1".to_owned(), + }; + let mut state = AcpxProviderState::new("run-1").unwrap(); + state.begin_turn("turn-1").unwrap(); + let mut ids = Vec::new(); + for (sequence, payload) in [ + (1, json!({"type":"text_delta","text":"Preface. "})), + ( + 2, + json!({"type":"text_delta","messageId":"first","text":"Progress."}), + ), + ( + 3, + json!({"type":"text_delta","messageId":"second","text":"Final "}), + ), + (4, json!({"type":"text_delta","text":"response."})), + ] { + let emitted = state + .accept_event(&event( + sequence, + GeneratedAcpxSidecarEventType::RuntimeEvent, + Some("turn-1"), + payload, + )) + .unwrap(); + ids.push( + project_acpx_state_event(&context, &emitted[0]).unwrap()[0].payload["itemId"].clone(), + ); + } + assert_eq!(ids[0], ids[1]); + assert_ne!(ids[1], ids[2]); + assert_eq!(ids[2], ids[3]); + let terminal = state + .accept_event(&event( + 5, + GeneratedAcpxSidecarEventType::RuntimeTurnTerminal, + Some("turn-1"), + json!({"status":"completed"}), + )) + .unwrap(); + let final_message = project_acpx_state_event(&context, &terminal[0]).unwrap(); + assert_eq!(final_message[0].payload["itemId"], ids[3]); + assert_eq!(final_message[0].payload["text"], "Final response."); } #[test] @@ -333,9 +397,16 @@ fn pi_native_boundary_order_and_terminal_completeness_fail_closed() { #[test] fn preserves_an_idless_prefix_when_the_provider_begins_identifying_deltas() { + let context = AcpxEventProjectionContext { + run_id: "run-1".to_owned(), + normalized_session_id: "session-1".to_owned(), + turn_id: "turn-1".to_owned(), + provider_turn_id: None, + item_id: "item-1".to_owned(), + }; let mut state = AcpxProviderState::new("run-1").unwrap(); state.begin_turn("turn-1").unwrap(); - state + let prefix = state .accept_event(&event( 1, GeneratedAcpxSidecarEventType::RuntimeEvent, @@ -343,7 +414,7 @@ fn preserves_an_idless_prefix_when_the_provider_begins_identifying_deltas() { json!({"type":"text_delta","text":"Preface.\n\n"}), )) .unwrap(); - state + let identified = state .accept_event(&event( 2, GeneratedAcpxSidecarEventType::RuntimeEvent, @@ -364,6 +435,14 @@ fn preserves_an_idless_prefix_when_the_provider_begins_identifying_deltas() { AcpxProviderStateEvent::AssistantMessage { text, .. } if text == "Preface.\n\nFinal response." )); + let prefix = project_acpx_state_event(&context, &prefix[0]).unwrap(); + let identified = project_acpx_state_event(&context, &identified[0]).unwrap(); + let final_message = project_acpx_state_event(&context, &terminal[0]).unwrap(); + assert_eq!(prefix[0].payload["itemId"], identified[0].payload["itemId"]); + assert_eq!( + prefix[0].payload["itemId"], + final_message[0].payload["itemId"] + ); } #[test] diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/local_integrity_boundary_golden.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/local_integrity_boundary_golden.rs index c30b161b5d..5f15d7ef22 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/local_integrity_boundary_golden.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/local_integrity_boundary_golden.rs @@ -256,7 +256,7 @@ fn acpx_projection_matches_shared_plan_question_final_and_terminal_identity() { }, &AcpxProviderStateEvent::AssistantMessage { turn_id: context.turn_id.clone(), - message_id: None, + provider_item_id: None, text: expected_final["text"] .as_str() .expect("final text") diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index 862b38bfb2..399abc8274 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -403,6 +403,28 @@ describe("transcriptToTaskChatItems tool_call updates", () => { ]); }); + it.each([ + { content: "hello", count: 3 }, + { output: "hello", count: 3, exit_code: 0 }, + { stdout: "hello", error: { code: "missing_input" }, exit_code: 2 }, + ])("preserves every field in arbitrary JSON tool output: %j", (output) => { + const content = JSON.stringify(output); + const items = transcriptToTaskChatItems([ + toolCall("terminal", { command: "print JSON" }), + { kind: "tool_result", ts: TS, toolUseId: "tool-terminal", content, isError: false }, + ], opts); + expect(items).toMatchObject([{ kind: "tool", detail: content }]); + }); + + it("retains distinct output streams in a known command result", () => { + const items = transcriptToTaskChatItems([ + toolCall("terminal", { command: "run checks" }), + { kind: "tool_result", ts: TS, toolUseId: "tool-terminal", isError: true, + content: JSON.stringify({ output: "Progress", stdout: "Checks failed", stderr: "Missing input", exit_code: 2 }) }, + ], opts); + expect(items).toMatchObject([{ kind: "tool", detail: "Progress\nChecks failed\nMissing input\nExit code: 2" }]); + }); + 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 fb8a3d81c3..a523333def 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -566,9 +566,17 @@ function formatToolResultDetail(content: unknown): string | undefined { 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] + const commandFields = ["output", "stdout", "stderr", "error"]; + const isCommandResult = typeof output.exit_code === "number" + && Number.isFinite(output.exit_code) + && Object.entries(output).every(([key, value]) => + key === "exit_code" || (commandFields.includes(key) + && (value == null || typeof value === "string"))); + const parts = commandFields.map((key) => output[key]) .filter((value): value is string => typeof value === "string" && value.trim().length > 0); - if (parts.length > 0) { + // Only unwrap the complete command-result shape. Arbitrary JSON printed by + // a command (including objects with a "content" field) remains inspectable. + if (isCommandResult && 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}`; diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 9d555e38a8..3cff4952df 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import type { HeartbeatRunEvent } from "@paperclipai/shared"; import { nativeRunEventsToTranscript } from "./native-run-events"; +import { transcriptToTaskChatItems } from "../task-chat/transcript-adapter"; const RUN_ID = "10000000-0000-4000-8000-000000000001"; @@ -118,6 +119,23 @@ describe("provider notice presentation", () => { }); describe("nativeRunEventsToTranscript", () => { + it("keeps JSON printed through native command output intact in the expanded tool", () => { + const output = JSON.stringify({ content: "hello", count: 3 }); + const entries = nativeRunEventsToTranscript([ + event(1, "tool.execution.started", { + schema: "paperclip.tool.execution.v1", executionId: "json-command", + transport: "process", operation: "execute", name: "terminal", status: "running", + input: { cmd: "print JSON" }, + }), + event(2, "tool.execution.completed", { + schema: "paperclip.tool.execution.v1", executionId: "json-command", + transport: "process", operation: "execute", name: "terminal", status: "completed", output, + }), + ]); + const items = transcriptToTaskChatItems(entries, { runId: RUN_ID, running: false }); + expect(items).toContainEqual(expect.objectContaining({ kind: "tool", detail: output })); + }); + 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([