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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 05:19:40 -05:00
1 parent 2d9e483cb9
commit 334db53750
10 files changed
+239 -32

No files matched your search

+59 -5
View File
@@ -1,8 +1,8 @@
# Hermes native runner implementation # Hermes native runner implementation
Status (updated 2026-10-07): implementation candidate; **not qualified**. Status (updated 2026-10-09): implementation candidate; **not qualified**.
Current branch: `codex/hermes-qualification`; stacked on Current branch: `codex/hermes-release-qualification`; stacked on
`codex/hermes-native-runner` and `codex/hermes-routines`. `codex/hermes-qualification`, `codex/hermes-native-runner` and `codex/hermes-routines`.
## Accepted outcome ## 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] Managed memory/skills, per-turn lifecycle and real routine-service binding.
- [x] UI/configuration/contracts, documentation and package distribution. - [x] UI/configuration/contracts, documentation and package distribution.
- [x] Focused TS/Python tests and production-path macOS execution with a deterministic model server. - [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. - [ ] 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. - [x] Complete the final local aggregate test invocation and classify its failures.
- [ ] Complete live release qualification. - [ ] 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 browser scratch roots are removed. Logs, screenshots, failed attempts and
required runtime assets remain available. No local Docker or Rust build runs, required runtime assets remain available. No local Docker or Rust build runs,
and no paid inference or credential transfer occurs in these browser checks. 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.
+4 -1
View File
@@ -108,7 +108,10 @@ extend the task's execution deadline.
- Assistant message identity v2 includes the provider message and provider turn. - Assistant message identity v2 includes the provider message and provider turn.
A final snapshot replaces only its own deltas, retaining preceding commentary A final snapshot replaces only its own deltas, retaining preceding commentary
and steered messages. Providers without message IDs retain the original 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. 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 - Native questions publish a 65,536-character limit for text and custom
answers. The form, canonical response validation and bridge count UTF-16 answers. The form, canonical response validation and bridge count UTF-16
@@ -72,7 +72,7 @@ pub enum AcpxProviderStateEvent {
SemanticResult(AcpxSemanticResult), SemanticResult(AcpxSemanticResult),
AssistantMessage { AssistantMessage {
turn_id: String, turn_id: String,
message_id: Option<String>, provider_item_id: Option<String>,
text: String, text: String,
}, },
TurnTerminal { TurnTerminal {
@@ -110,6 +110,7 @@ pub struct AcpxProviderState {
plan_revision: u64, plan_revision: u64,
assistant_text: String, assistant_text: String,
assistant_message_id: Option<String>, assistant_message_id: Option<String>,
assistant_item_id: Option<String>,
pi_assistant_message: Option<String>, pi_assistant_message: Option<String>,
pi_assistant_message_ids: BTreeSet<String>, pi_assistant_message_ids: BTreeSet<String>,
pending_tools: BTreeMap<String, AcpxPendingTool>, pending_tools: BTreeMap<String, AcpxPendingTool>,
@@ -129,6 +130,7 @@ impl AcpxProviderState {
plan_revision: 0, plan_revision: 0,
assistant_text: String::new(), assistant_text: String::new(),
assistant_message_id: None, assistant_message_id: None,
assistant_item_id: None,
pi_assistant_message: None, pi_assistant_message: None,
pi_assistant_message_ids: BTreeSet::new(), pi_assistant_message_ids: BTreeSet::new(),
pending_tools: BTreeMap::new(), pending_tools: BTreeMap::new(),
@@ -223,6 +225,7 @@ impl AcpxProviderState {
self.plan_revision = 0; self.plan_revision = 0;
self.assistant_text.clear(); self.assistant_text.clear();
self.assistant_message_id = None; self.assistant_message_id = None;
self.assistant_item_id = None;
self.pi_assistant_message = None; self.pi_assistant_message = None;
self.pi_assistant_message_ids.clear(); self.pi_assistant_message_ids.clear();
self.semantic_result = None; self.semantic_result = None;
@@ -389,13 +392,14 @@ impl AcpxProviderState {
if status == AcpxTurnStatus::Completed && !self.assistant_text.is_empty() { if status == AcpxTurnStatus::Completed && !self.assistant_text.is_empty() {
events.push(AcpxProviderStateEvent::AssistantMessage { events.push(AcpxProviderStateEvent::AssistantMessage {
turn_id: turn_id.clone(), 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), text: std::mem::take(&mut self.assistant_text),
}); });
} else { } else {
self.assistant_text.clear(); self.assistant_text.clear();
} }
self.assistant_message_id = None; self.assistant_message_id = None;
self.assistant_item_id = None;
self.pi_assistant_message = None; self.pi_assistant_message = None;
self.pi_assistant_message_ids.clear(); self.pi_assistant_message_ids.clear();
events.push(AcpxProviderStateEvent::TurnTerminal { events.push(AcpxProviderStateEvent::TurnTerminal {
@@ -529,7 +533,7 @@ impl AcpxProviderState {
let starts_new_message = provider_message_id.as_ref().is_some_and(|message_id| { let starts_new_message = provider_message_id.as_ref().is_some_and(|message_id| {
self.assistant_message_id self.assistant_message_id
.as_ref() .as_ref()
.is_none_or(|current| current != message_id) .is_some_and(|current| current != message_id)
}); });
let raw_text = payload let raw_text = payload
.get("text") .get("text")
@@ -594,6 +598,7 @@ impl AcpxProviderState {
// the task UI and can promote intermediate prose as final output. // the task UI and can promote intermediate prose as final output.
if starts_new_message || native_boundary_clears { if starts_new_message || native_boundary_clears {
self.assistant_text.clear(); self.assistant_text.clear();
self.assistant_item_id = None;
} }
if self if self
.assistant_text .assistant_text
@@ -662,7 +667,7 @@ impl AcpxProviderState {
.as_deref() .as_deref()
.expect("a decoded runtime event has a turn binding"); .expect("a decoded runtime event has a turn binding");
let fallback_item_id = format!("acpx-event-{}", event.sequence); let fallback_item_id = format!("acpx-event-{}", event.sequence);
let events = normalize_acpx_runtime_event( let mut events = normalize_acpx_runtime_event(
kind, kind,
&payload, &payload,
tool_operation, tool_operation,
@@ -670,6 +675,26 @@ impl AcpxProviderState {
turn_id, turn_id,
self.provider_requests, 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 { if let Some(revision) = next_plan_revision {
self.plan_revision = revision; self.plan_revision = revision;
} }
@@ -378,13 +378,10 @@ pub fn project_acpx_state_event(
} }
AcpxProviderStateEvent::AssistantMessage { AcpxProviderStateEvent::AssistantMessage {
turn_id, turn_id,
message_id, provider_item_id,
text, text,
} => { } => {
require_projected_turn(context, turn_id)?; 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( one(
"item.completed", "item.completed",
EventPriority::P1, EventPriority::P1,
@@ -151,7 +151,7 @@ fn keeps_durable_correlation_separate_from_the_active_provider_turn() {
&context, &context,
&AcpxProviderStateEvent::AssistantMessage { &AcpxProviderStateEvent::AssistantMessage {
turn_id: "provider-turn-1".to_owned(), turn_id: "provider-turn-1".to_owned(),
message_id: None, provider_item_id: None,
text: "Done".to_owned(), text: "Done".to_owned(),
}, },
) )
@@ -339,7 +339,7 @@ fn projects_assistant_terminal_and_diagnostic_events_fail_closed() {
let assistant = project(AcpxProviderStateEvent::AssistantMessage { let assistant = project(AcpxProviderStateEvent::AssistantMessage {
turn_id: "turn-1".to_owned(), 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(), text: "Done".to_owned(),
}); });
assert_eq!(assistant[0].event_type, "item.completed"); 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() { fn recovery_preserves_the_preceding_provider_turn_answer() {
let first = project(AcpxProviderStateEvent::AssistantMessage { let first = project(AcpxProviderStateEvent::AssistantMessage {
turn_id: "turn-1".to_owned(), turn_id: "turn-1".to_owned(),
message_id: None, provider_item_id: None,
text: "Useful answer".to_owned(), text: "Useful answer".to_owned(),
}); });
let mut recovery = context(); let mut recovery = context();
@@ -638,7 +638,7 @@ fn recovery_preserves_the_preceding_provider_turn_answer() {
&recovery, &recovery,
&AcpxProviderStateEvent::AssistantMessage { &AcpxProviderStateEvent::AssistantMessage {
turn_id: "recovery-turn".to_owned(), turn_id: "recovery-turn".to_owned(),
message_id: None, provider_item_id: None,
text: "Recovery update".to_owned(), text: "Recovery update".to_owned(),
}, },
) )
@@ -673,6 +673,7 @@ fn assistant_message_identity_matches_its_snapshot_and_is_scoped_to_the_provider
1, 1,
) )
.remove(0); .remove(0);
let provider_item_id = activity.payload["itemId"].as_str().unwrap().to_owned();
let streamed = project(AcpxProviderStateEvent::Activity(activity.clone())); let streamed = project(AcpxProviderStateEvent::Activity(activity.clone()));
assert_eq!( assert_eq!(
streamed, streamed,
@@ -680,7 +681,7 @@ fn assistant_message_identity_matches_its_snapshot_and_is_scoped_to_the_provider
); );
let final_message = project(AcpxProviderStateEvent::AssistantMessage { let final_message = project(AcpxProviderStateEvent::AssistantMessage {
turn_id: "turn-1".to_owned(), 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(), text: "Done".to_owned(),
}); });
assert_eq!( assert_eq!(
@@ -697,7 +698,7 @@ fn assistant_message_identity_matches_its_snapshot_and_is_scoped_to_the_provider
&recovery, &recovery,
&AcpxProviderStateEvent::AssistantMessage { &AcpxProviderStateEvent::AssistantMessage {
turn_id: "recovery-turn".to_owned(), 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(), text: "Recovered".to_owned(),
}, },
) )
@@ -70,8 +70,9 @@ fn accumulates_assistant_text_preserves_reasoning_and_flushes_before_terminal()
&& event.payload["text"] == "Checking the result." && event.payload["text"] == "Checking the result."
)); ));
let mut assistant_item_id = None;
for (sequence, text) in [(3, "Hello "), (4, "world")] { for (sequence, text) in [(3, "Hello "), (4, "world")] {
state let emitted = state
.accept_event(&event( .accept_event(&event(
sequence, sequence,
GeneratedAcpxSidecarEventType::RuntimeEvent, GeneratedAcpxSidecarEventType::RuntimeEvent,
@@ -79,6 +80,14 @@ fn accumulates_assistant_text_preserves_reasoning_and_flushes_before_terminal()
json!({"type":"text_delta","text":text}), json!({"type":"text_delta","text":text}),
)) ))
.unwrap(); .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 let terminal = state
.accept_event(&event( .accept_event(&event(
@@ -90,8 +99,8 @@ fn accumulates_assistant_text_preserves_reasoning_and_flushes_before_terminal()
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
&terminal[0], &terminal[0],
AcpxProviderStateEvent::AssistantMessage { turn_id, message_id, text } AcpxProviderStateEvent::AssistantMessage { turn_id, provider_item_id, text }
if turn_id == "turn-1" && message_id.is_none() && text == "Hello world" if turn_id == "turn-1" && provider_item_id == &assistant_item_id && text == "Hello world"
)); ));
assert!(matches!( assert!(matches!(
&terminal[1], &terminal[1],
@@ -183,8 +192,8 @@ fn promotes_only_the_latest_provider_message_as_the_terminal_reply() {
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
&terminal[0], &terminal[0],
AcpxProviderStateEvent::AssistantMessage { message_id, text, .. } AcpxProviderStateEvent::AssistantMessage { provider_item_id, text, .. }
if message_id.as_deref() == Some("message-2") if provider_item_id.as_deref() == Some("message-2")
&& text == "Second paragraph.\n\nStill final." && text == "Second paragraph.\n\nStill final."
)); ));
let projected_final = project_acpx_state_event(&context, &terminal[0]).unwrap(); 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(), 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]); assert_eq!(ids[1], ids[2]);
let terminal = state let terminal = state
.accept_event(&event( .accept_event(&event(
@@ -239,7 +248,62 @@ fn an_unlabelled_continuation_keeps_the_current_message_identity() {
.unwrap(); .unwrap();
let final_message = project_acpx_state_event(&context, &terminal[0]).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["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] #[test]
@@ -333,9 +397,16 @@ fn pi_native_boundary_order_and_terminal_completeness_fail_closed() {
#[test] #[test]
fn preserves_an_idless_prefix_when_the_provider_begins_identifying_deltas() { 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(); let mut state = AcpxProviderState::new("run-1").unwrap();
state.begin_turn("turn-1").unwrap(); state.begin_turn("turn-1").unwrap();
state let prefix = state
.accept_event(&event( .accept_event(&event(
1, 1,
GeneratedAcpxSidecarEventType::RuntimeEvent, 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"}), json!({"type":"text_delta","text":"Preface.\n\n"}),
)) ))
.unwrap(); .unwrap();
state let identified = state
.accept_event(&event( .accept_event(&event(
2, 2,
GeneratedAcpxSidecarEventType::RuntimeEvent, GeneratedAcpxSidecarEventType::RuntimeEvent,
@@ -364,6 +435,14 @@ fn preserves_an_idless_prefix_when_the_provider_begins_identifying_deltas() {
AcpxProviderStateEvent::AssistantMessage { text, .. } AcpxProviderStateEvent::AssistantMessage { text, .. }
if text == "Preface.\n\nFinal response." 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] #[test]
@@ -256,7 +256,7 @@ fn acpx_projection_matches_shared_plan_question_final_and_terminal_identity() {
}, },
&AcpxProviderStateEvent::AssistantMessage { &AcpxProviderStateEvent::AssistantMessage {
turn_id: context.turn_id.clone(), turn_id: context.turn_id.clone(),
message_id: None, provider_item_id: None,
text: expected_final["text"] text: expected_final["text"]
.as_str() .as_str()
.expect("final text") .expect("final text")
@@ -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 { function update(toolUseId: string, status: string): TranscriptEntry {
// Mirrors what a persisted acpx tool_call_update line parses to: the // Mirrors what a persisted acpx tool_call_update line parses to: the
// literal placeholder name plus a synthesized { text, status } input. // literal placeholder name plus a synthesized { text, status } input.
@@ -566,9 +566,17 @@ function formatToolResultDetail(content: unknown): string | undefined {
try { try {
const result: unknown = typeof content === "string" ? JSON.parse(content) : content; const result: unknown = typeof content === "string" ? JSON.parse(content) : content;
const output = objectRecord(result); 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); .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"); text = [...new Set(parts)].join("\n");
if (typeof output.exit_code === "number" && output.exit_code !== 0) { if (typeof output.exit_code === "number" && output.exit_code !== 0) {
text += `\nExit code: ${output.exit_code}`; text += `\nExit code: ${output.exit_code}`;
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { HeartbeatRunEvent } from "@paperclipai/shared"; import type { HeartbeatRunEvent } from "@paperclipai/shared";
import { nativeRunEventsToTranscript } from "./native-run-events"; import { nativeRunEventsToTranscript } from "./native-run-events";
import { transcriptToTaskChatItems } from "../task-chat/transcript-adapter";
const RUN_ID = "10000000-0000-4000-8000-000000000001"; const RUN_ID = "10000000-0000-4000-8000-000000000001";
@@ -118,6 +119,23 @@ describe("provider notice presentation", () => {
}); });
describe("nativeRunEventsToTranscript", () => { 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) => { 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 input = { [key]: "printf 'native output'", cwd: "/workspace" };
const transcript = nativeRunEventsToTranscript([ const transcript = nativeRunEventsToTranscript([