From 17ce15bc44f75f33365b405967ffaffd67c198fe Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 03:39:48 -0500 Subject: [PATCH] fix(hermes): preserve assistant messages and bounded source setup --- doc/plans/2026-10-06-hermes-native-runner.md | 33 ++++++++ packages/paperclip-runner/docs/hermes.md | 9 +++ packages/paperclip-runner/package.json | 2 +- .../runner-core/src/acpx_provider_state.rs | 11 ++- .../crates/runner-core/src/provider_events.rs | 56 ++++++++++---- .../tests/acpx_event_projection.rs | 65 ++++++++++++++++ .../runner-core/tests/acpx_provider_state.rs | 75 ++++++++++++++++++- .../tests/local_integrity_boundary_golden.rs | 1 + .../scripts/hermes-source-download.test.mjs | 70 +++++++++++++++++ .../scripts/provision-hermes.mjs | 36 ++++++++- .../scripts/qualify-hermes-runnerd.test.mjs | 11 +++ .../transcript/native-run-events.test.ts | 62 +++++++++++++++ 12 files changed, 405 insertions(+), 26 deletions(-) create mode 100644 packages/paperclip-runner/scripts/hermes-source-download.test.mjs diff --git a/doc/plans/2026-10-06-hermes-native-runner.md b/doc/plans/2026-10-06-hermes-native-runner.md index d4f41ac4d37..f14b92c80c3 100644 --- a/doc/plans/2026-10-06-hermes-native-runner.md +++ b/doc/plans/2026-10-06-hermes-native-runner.md @@ -1817,3 +1817,36 @@ failures retain their verdict; the next checks must run the changed source. Full paid account, subscription, credential lifecycle, attachment, browser interaction, published Linux package and actual Daytona qualification remain required. Hermes and all four PRs remain pending qualification. + +### 2026-10-09 assistant message boundaries and setup throttling + +The earlier native controls browser audit records distinct native assistant +messages projected onto one canonical item. The final snapshot therefore +suppresses initial streamed text. The reducer now carries the retained native +message identity to completion, and projection uses assistant identity v2, +scoped to the message and provider turn. Unlabelled providers retain their +existing identity, and old PRP v1 events replay unchanged. Earlier commentary +remains progress; the final snapshot still contains only the latest message. +Rust regression assertions cover distinct messages, unlabelled continuations, +opaque IDs and recovery. The production native steering fixture adds the same +boundary and final-deduplication checks. These Rust/native assertions still +require fresh cloud execution; no local Rust build is run. + +All 168 transcript/adapter/golden UI assertions pass, including preceding text +retention and replacement behavior. UI TypeScript and token gates pass. This +is fixture proof, not rendered browser acceptance of the new native binary. + +The current Mac CI job fails during source provisioning with GitHub HTTP 429. +Setup now downloads the immutable codeload archive without GitHub API quota, +retains the exact existing archive checksum, and retries transient responses +at most three times with bounded `Retry-After` handling. A real unauthenticated +download matches the existing SHA-256 +`b71f4b7d4fe5b158ed9bbbeb73f5eb107f2f05e54bd582d32c2d9f718b58d9b0`. +All ten focused download/setup assertions pass. No runtime installation, +Docker build, model call or credential transfer occurs in that download check. + +Foundation `7a0329466485b40f33f38889964da4baefb58ec7` and core qualification +`4f40b5b6661b3fefa6e149b70f18ee42b4fc3ecd` have no failed current checks. +Native `a8805d61af611574046139377801fcb07e35dc25` still needs diagnosis of a +first-test route import timeout and a denied-retry browser failure; previous +CI verdicts are retained. Full release qualification remains incomplete. diff --git a/packages/paperclip-runner/docs/hermes.md b/packages/paperclip-runner/docs/hermes.md index 5602aa6c155..1ddb542609b 100644 --- a/packages/paperclip-runner/docs/hermes.md +++ b/packages/paperclip-runner/docs/hermes.md @@ -13,6 +13,10 @@ The source-owned declaration is `src/providers/hermes/version.json`: release 3.12.14, ACP SDK 0.9.0 and ACPX 0.13.1. Provisioning verifies the source archive and upstream `uv.lock`, then installs the locked ACP, MCP, Anthropic, Bedrock and Google extras. Use uv 0.12.17. No installation occurs during an agent turn. +The source download uses GitHub's immutable codeload archive and checks the +existing archive digest. Transient HTTP failures receive at most three attempts +within two minutes; throttling respects `Retry-After` up to 30 seconds and longer +delays produce an actionable setup failure. Downloads require no credentials. ```sh # From the repository root, with an absolute, nonexistent destination: @@ -101,6 +105,11 @@ extend the task's execution deadline. - Reasoning and assistant text use distinct message identities. Native tool call IDs preserve overlapping calls and edit snapshots. +- 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 + 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 code units consistently, so an accepted answer can resume the native callback. diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 2b1be164b03..1559668775b 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -75,7 +75,7 @@ "typecheck:browser": "tsc -p tsconfig.browser.json --noEmit", "test": "pnpm run test:typescript && pnpm run test:rust", "test:typescript": "pnpm run test:typescript:prep && vitest run", - "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/hermes-provisioner-layout.test.mjs scripts/hermes-provisioning-config.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs", + "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/hermes-provisioner-layout.test.mjs scripts/hermes-source-download.test.mjs scripts/hermes-provisioning-config.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs", "test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs", "test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace", "test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider", 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 b5d860a17e8..19f0c2aaa72 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,6 +72,7 @@ pub enum AcpxProviderStateEvent { SemanticResult(AcpxSemanticResult), AssistantMessage { turn_id: String, + message_id: Option, text: String, }, TurnTerminal { @@ -388,6 +389,7 @@ 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(), text: std::mem::take(&mut self.assistant_text), }); } else { @@ -527,7 +529,7 @@ impl AcpxProviderState { let starts_new_message = provider_message_id.as_ref().is_some_and(|message_id| { self.assistant_message_id .as_ref() - .is_some_and(|current| current != message_id) + .is_none_or(|current| current != message_id) }); let raw_text = payload .get("text") @@ -606,6 +608,13 @@ impl AcpxProviderState { self.assistant_text.push_str(raw_text); if provider_message_id.is_some() { self.assistant_message_id = provider_message_id; + } else if let Some(message_id) = self.assistant_message_id.as_ref() { + // An unlabelled continuation belongs to the retained message. + // Give its progress event the same identity as the final snapshot. + payload + .as_object_mut() + .expect("a decoded ACPX runtime delta is an object") + .insert("messageId".to_owned(), Value::String(message_id.clone())); } } if kind == AcpxRuntimeEventKind::SemanticResult { 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 6f58a92d831..5e549d1ca14 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 @@ -104,14 +104,29 @@ impl AcpxEventProjectionContext { self.provider_turn_id.as_deref().unwrap_or(&self.turn_id) } - fn assistant_item_id(&self) -> String { - // Recovery can submit multiple provider turns within one PRP run. Keep - // each delivered answer distinct while coalescing its streaming deltas. - acpx_message_item_id( - "", - &format!("{}:{}", self.item_id, self.active_provider_turn_id()), - "assistant", - ) + fn assistant_item_id(&self, provider_item_id: Option<&str>) -> String { + let unlabelled_item_id = + acpx_message_item_id("", self.active_provider_turn_id(), "assistant-message"); + let Some(provider_item_id) = + provider_item_id.filter(|item_id| *item_id != unlabelled_item_id) + else { + // Keep the original identity for providers without message IDs. + return acpx_message_item_id( + "", + &format!("{}:{}", self.item_id, self.active_provider_turn_id()), + "assistant", + ); + }; + // A turn can contain several assistant messages, and recovery can reuse + // provider message IDs in another turn. Scope each message to both. + let mut digest = Sha256::new(); + digest.update(b"paperclip.acpx.assistant-item.v2\0"); + digest.update(self.item_id.as_bytes()); + digest.update(b"\0"); + digest.update(self.active_provider_turn_id().as_bytes()); + digest.update(b"\0"); + digest.update(provider_item_id.as_bytes()); + format!("acpx-assistant-{:x}", digest.finalize()) } fn correlation(&self) -> Value { @@ -149,17 +164,19 @@ pub fn project_acpx_state_event( .payload .as_object_mut() .expect("normalized ACPX item delta has an object payload"); - if let Some(provider_item_id) = payload + let provider_item_id = payload .get("itemId") .and_then(Value::as_str) - .filter(|item_id| *item_id != context.item_id.as_str()) - .map(str::to_owned) - { - payload.insert("providerItemId".to_owned(), Value::String(provider_item_id)); + .map(str::to_owned); + if let Some(provider_item_id) = provider_item_id.as_ref() { + payload.insert( + "providerItemId".to_owned(), + Value::String(provider_item_id.clone()), + ); } payload.insert( "itemId".to_owned(), - Value::String(context.assistant_item_id()), + Value::String(context.assistant_item_id(provider_item_id.as_deref())), ); } Ok(vec![event]) @@ -359,14 +376,21 @@ pub fn project_acpx_state_event( )?]) } } - AcpxProviderStateEvent::AssistantMessage { turn_id, text } => { + AcpxProviderStateEvent::AssistantMessage { + turn_id, + message_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, json!({ "provider": "acpx", - "itemId": context.assistant_item_id(), + "itemId": context.assistant_item_id(provider_item_id.as_deref()), "kind": "agentMessage", "status": "completed", "channel": "final", 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 d6f6c519895..59cecd28fc3 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,6 +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, text: "Done".to_owned(), }, ) @@ -338,6 +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()), text: "Done".to_owned(), }); assert_eq!(assistant[0].event_type, "item.completed"); @@ -627,6 +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, text: "Useful answer".to_owned(), }); let mut recovery = context(); @@ -635,6 +638,7 @@ fn recovery_preserves_the_preceding_provider_turn_answer() { &recovery, &AcpxProviderStateEvent::AssistantMessage { turn_id: "recovery-turn".to_owned(), + message_id: None, text: "Recovery update".to_owned(), }, ) @@ -644,6 +648,67 @@ fn recovery_preserves_the_preceding_provider_turn_answer() { assert_eq!(second[0].payload["text"], "Recovery update"); } +#[test] +fn assistant_message_identity_matches_its_snapshot_and_is_scoped_to_the_provider_turn() { + use paperclip_runner_core::acpx_event_payload::AcpxRuntimeEventKind; + use paperclip_runner_core::provider_events::normalize_acpx_runtime_event; + + let opaque_id = format!("message / {}", "é".repeat(200)); + for message_id in [ + None, + Some("item-1"), + Some("message-1"), + Some(opaque_id.as_str()), + ] { + let mut payload = json!({"type":"text_delta","text":"Done"}); + if let Some(message_id) = message_id { + payload["messageId"] = Value::String(message_id.to_owned()); + } + let activity = normalize_acpx_runtime_event( + AcpxRuntimeEventKind::TextDelta, + &payload, + None, + "event-1", + "turn-1", + 1, + ) + .remove(0); + let streamed = project(AcpxProviderStateEvent::Activity(activity.clone())); + assert_eq!( + streamed, + project(AcpxProviderStateEvent::Activity(activity)), + ); + let final_message = project(AcpxProviderStateEvent::AssistantMessage { + turn_id: "turn-1".to_owned(), + message_id: message_id.map(str::to_owned), + text: "Done".to_owned(), + }); + assert_eq!( + streamed[0].payload["itemId"], + final_message[0].payload["itemId"] + ); + let item_id = final_message[0].payload["itemId"].as_str().unwrap(); + assert!(item_id.starts_with("acpx-assistant-")); + assert_eq!(item_id.len(), "acpx-assistant-".len() + 64); + + let mut recovery = context(); + recovery.provider_turn_id = Some("recovery-turn".to_owned()); + let recovered = project_acpx_state_event( + &recovery, + &AcpxProviderStateEvent::AssistantMessage { + turn_id: "recovery-turn".to_owned(), + message_id: message_id.map(str::to_owned), + text: "Recovered".to_owned(), + }, + ) + .unwrap(); + assert_ne!( + final_message[0].payload["itemId"], + recovered[0].payload["itemId"] + ); + } +} + #[test] fn cursor_plan_request_item_matches_tool_projection_and_survives_terminal_reconstruction() { use paperclip_runner_core::acpx_event_payload::AcpxRuntimeEventKind; 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 ce81b986920..c95d6d9e2af 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 @@ -90,8 +90,8 @@ fn accumulates_assistant_text_preserves_reasoning_and_flushes_before_terminal() .unwrap(); assert!(matches!( &terminal[0], - AcpxProviderStateEvent::AssistantMessage { turn_id, text } - if turn_id == "turn-1" && text == "Hello world" + AcpxProviderStateEvent::AssistantMessage { turn_id, message_id, text } + if turn_id == "turn-1" && message_id.is_none() && text == "Hello world" )); assert!(matches!( &terminal[1], @@ -138,6 +138,14 @@ fn promotes_only_the_latest_provider_message_as_the_terminal_reply() { let mut state = AcpxProviderState::new("run-1").unwrap(); state.begin_turn("turn-1").unwrap(); let mut progress = Vec::new(); + 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 projected_ids = Vec::new(); for (sequence, message_id, text) in [ (1, "message-1", "First paragraph."), @@ -157,9 +165,14 @@ fn promotes_only_the_latest_provider_message_as_the_terminal_reply() { AcpxProviderStateEvent::Activity(event) if event.payload["channel"] == "progress" )); + let projected = project_acpx_state_event(&context, &emitted[0]).unwrap(); + assert_eq!(projected[0].payload["text"], text); + projected_ids.push(projected[0].payload["itemId"].clone()); progress.push(emitted[0].clone()); } assert_eq!(progress.len(), 3); + assert_ne!(projected_ids[0], projected_ids[1]); + assert_eq!(projected_ids[1], projected_ids[2]); let terminal = state .accept_event(&event( 4, @@ -170,9 +183,63 @@ fn promotes_only_the_latest_provider_message_as_the_terminal_reply() { .unwrap(); assert!(matches!( &terminal[0], - AcpxProviderStateEvent::AssistantMessage { text, .. } - if text == "Second paragraph.\n\nStill final." + AcpxProviderStateEvent::AssistantMessage { message_id, text, .. } + if message_id.as_deref() == Some("message-2") + && text == "Second paragraph.\n\nStill final." )); + let projected_final = project_acpx_state_event(&context, &terminal[0]).unwrap(); + assert_eq!(projected_final[0].payload["itemId"], projected_ids[2]); + assert_ne!(projected_final[0].payload["itemId"], projected_ids[0]); +} + +#[test] +fn an_unlabelled_continuation_keeps_the_current_message_identity() { + 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":"Unlabelled progress."}), + ), + ( + 2, + json!({"type":"text_delta","messageId":"message-1","text":"Hello "}), + ), + (3, json!({"type":"text_delta","text":"world"})), + ] { + 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_ne!(ids[0], ids[1]); + assert_eq!(ids[1], ids[2]); + let terminal = state + .accept_event(&event( + 4, + 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[2]); + assert_eq!(final_message[0].payload["text"], "Hello world"); } #[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 6a06c76650f..c30b161b5d7 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,6 +256,7 @@ fn acpx_projection_matches_shared_plan_question_final_and_terminal_identity() { }, &AcpxProviderStateEvent::AssistantMessage { turn_id: context.turn_id.clone(), + message_id: None, text: expected_final["text"] .as_str() .expect("final text") diff --git a/packages/paperclip-runner/scripts/hermes-source-download.test.mjs b/packages/paperclip-runner/scripts/hermes-source-download.test.mjs new file mode 100644 index 00000000000..bd33554361d --- /dev/null +++ b/packages/paperclip-runner/scripts/hermes-source-download.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; +import { downloadPinnedHermesArchive } from "./provision-hermes.mjs"; + +const archive = Buffer.from("pinned Hermes archive fixture"); +const version = { + commit: "f97608f178d1ffeca59860195ab7da295f7c8e5f", + archiveSha256: createHash("sha256").update(archive).digest("hex"), +}; + +test("source download uses the immutable archive and verifies its digest without credentials", async () => { + const bytes = await downloadPinnedHermesArchive(version, { request: async (url, options) => { + assert.equal(url, `https://codeload.github.com/NousResearch/hermes-agent/legacy.tar.gz/${version.commit}`); + assert.deepEqual(Object.keys(options), ["signal"]); + assert.ok(options.signal instanceof AbortSignal); + return new Response(archive); + } }); + assert.deepEqual(bytes, archive); +}); + +test("a throttled source download respects Retry-After before the verified response", async () => { + let calls = 0; + const waits = []; + const bytes = await downloadPinnedHermesArchive(version, { + request: async () => ++calls === 1 + ? new Response("throttled", { status: 429, headers: { "retry-after": "2" } }) + : new Response(archive), + wait: async milliseconds => waits.push(milliseconds), + }); + assert.deepEqual(bytes, archive); + assert.equal(calls, 2); + assert.deepEqual(waits, [2_000]); +}); + +test("persistent transient failures stop after three attempts", async () => { + let calls = 0; + const waits = []; + await assert.rejects(downloadPinnedHermesArchive(version, { + request: async () => { calls++; return new Response("unavailable", { status: 503 }); }, + wait: async milliseconds => waits.push(milliseconds), + }), /HTTP 503 after 3 attempts/); + assert.equal(calls, 3); + assert.deepEqual(waits, [1_000, 2_000]); +}); + +test("permanent failures and digest mismatches are never retried", async () => { + for (const [response, error] of [ + [new Response("not found", { status: 404 }), /HTTP 404/], + [new Response("changed archive"), /source digest mismatch/], + ]) { + let calls = 0; + await assert.rejects(downloadPinnedHermesArchive(version, { + request: async () => { calls++; return response; }, + wait: async () => assert.fail("This failure must not be retried"), + }), error); + assert.equal(calls, 1); + } +}); + +test("long throttling delays fail visibly rather than running an unbounded setup", async () => { + let calls = 0; + await assert.rejects(downloadPinnedHermesArchive(version, { + request: async () => { calls++; return new Response("throttled", { + status: 429, headers: { "retry-after": "3600" }, + }); }, + wait: async () => assert.fail("An excessive server delay must not be ignored"), + }), /rate limit expires/); + assert.equal(calls, 1); +}); diff --git a/packages/paperclip-runner/scripts/provision-hermes.mjs b/packages/paperclip-runner/scripts/provision-hermes.mjs index de948eadc97..4466cd7b373 100644 --- a/packages/paperclip-runner/scripts/provision-hermes.mjs +++ b/packages/paperclip-runner/scripts/provision-hermes.mjs @@ -3,11 +3,42 @@ import { execFileSync } from "node:child_process"; import { access, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; import { HERMES_CLOSURES } from "../src/drivers/acpx/hermes-distributions.ts"; import { hermesProvisioningConfig } from "./hermes-provisioning-config.mjs"; const digest = bytes => createHash("sha256").update(bytes).digest("hex"); +/** Download the same pinned GitHub archive without the unauthenticated API quota. */ +export async function downloadPinnedHermesArchive(version, { request = fetch, wait = delay } = {}) { + const deadline = Date.now() + 120_000; + const url = `https://codeload.github.com/NousResearch/hermes-agent/legacy.tar.gz/${version.commit}`; + for (let attempt = 1; attempt <= 3; attempt++) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("Hermes source download deadline exceeded; retry setup later"); + const response = await request(url, { signal: AbortSignal.timeout(Math.min(30_000, remaining)) }); + if (response.ok) { + const bytes = Buffer.from(await response.arrayBuffer()); + if (digest(bytes) !== version.archiveSha256) throw new Error("Hermes source digest mismatch"); + return bytes; + } + await response.body?.cancel(); + const failure = `Hermes source download failed: HTTP ${response.status}`; + if (![429, 500, 502, 503, 504].includes(response.status) || attempt === 3) { + throw new Error(`${failure}${attempt > 1 ? ` after ${attempt} attempts` : ""}; retry setup later`); + } + const retryAfter = response.headers.get("retry-after"); + const seconds = retryAfter === null ? NaN : Number(retryAfter); + const retryAt = retryAfter === null ? NaN : Date.parse(retryAfter); + const waitMs = Number.isFinite(seconds) ? Math.max(0, seconds * 1_000) + : Number.isFinite(retryAt) ? Math.max(0, retryAt - Date.now()) : attempt * 1_000; + if (waitMs > 30_000 || waitMs + 1 >= deadline - Date.now()) { + throw new Error(`${failure}; retry setup later after the download service's rate limit expires`); + } + await wait(waitMs); + } +} + /** Source provisioning and the public setup command share one pinned operation. */ export async function materializePinnedHermesDistribution({ destination, provider, materializer, verify }) { if (!destination || !isAbsolute(destination) || resolve(destination) !== destination || destination.includes("\0")) throw new Error("Hermes provisioning requires a new normalized absolute destination"); @@ -30,10 +61,7 @@ export async function materializePinnedHermesDistribution({ destination, provide try { uvVersion = execFileSync("uv", ["--version"], { env, encoding: "utf8", timeout: 10000 }); } catch (error) { if (error.code === "ENOENT") throw new Error("Hermes setup requires uv 0.12.17 on PATH. Install that version and retry setup."); throw error; } if (!/^uv 0\.12\.17(?:\s|$)/.test(uvVersion)) throw new Error("Hermes provisioning requires uv 0.12.17; install that version before provisioning"); - const response = await fetch(`https://api.github.com/repos/NousResearch/hermes-agent/tarball/${version.commit}`, { signal: AbortSignal.timeout(120_000) }); - if (!response.ok) throw new Error(`Hermes source download failed: HTTP ${response.status}`); - const bytes = Buffer.from(await response.arrayBuffer()); - if (digest(bytes) !== version.archiveSha256) throw new Error("Hermes source digest mismatch"); + const bytes = await downloadPinnedHermesArchive(version); const archive = join(temporary, "source.tar.gz"); await writeFile(archive, bytes, { mode: 0o600, flag: "wx" }); execFileSync("tar", ["-xzf", archive, "--strip-components=1", "-C", source], { env, timeout: 30000 }); diff --git a/packages/paperclip-runner/scripts/qualify-hermes-runnerd.test.mjs b/packages/paperclip-runner/scripts/qualify-hermes-runnerd.test.mjs index feb4d3d537e..764c07ba8ff 100644 --- a/packages/paperclip-runner/scripts/qualify-hermes-runnerd.test.mjs +++ b/packages/paperclip-runner/scripts/qualify-hermes-runnerd.test.mjs @@ -314,6 +314,17 @@ for (const { interactionKind, nativeQuestionAction, nativeSteering } of cases) t assert.equal(acknowledgements.length, 1, 'Steering must produce one canonical acknowledgement'); assert.equal(acknowledgements[0].turnId, steeringTurnId); assert.equal(acknowledgements[0].payload.status, 'acknowledged'); + const initial = events.find(event => event.eventType === 'item.delta' + && event.payload.kind === 'agentMessage' && event.payload.text.includes('NATIVE_RUNNER_STEER_READY')); + const final = events.find(event => event.eventType === 'item.completed' + && event.payload.kind === 'agentMessage' && event.payload.text.includes('Native Rust path completed.')); + assert.ok(initial && final, 'Both assistant messages must survive native steering'); + assert.notEqual(initial.payload.itemId, final.payload.itemId, + 'The final snapshot must not replace text from the preceding assistant message'); + const finalDeltas = events.filter(event => event.eventType === 'item.delta' + && event.payload.kind === 'agentMessage' && event.payload.itemId === final.payload.itemId); + assert.equal(finalDeltas.map(event => event.payload.text).join(''), final.payload.text, + 'The final snapshot must share the identity of its own streamed text'); await assert.rejects(session.steer({ turnId: steeringTurnId, message: { role: 'user', text: 'STALE_NATIVE_STEER' }, correlationId: 'stale-native-steer' }), /terminal|active turn/i); } diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 7c9f34ff3de..9d555e38a8a 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -559,6 +559,68 @@ describe("nativeRunEventsToTranscript", () => { ]); }); + it("keeps earlier assistant messages when the final message snapshot arrives", () => { + const streamed = [ + event(1, "item.delta", { + itemId: "acpx-assistant-initial", + kind: "agentMessage", + channel: "progress", + text: "Initial work is active.", + }), + event(2, "item.delta", { + itemId: "acpx-assistant-final", + kind: "agentMessage", + channel: "progress", + text: "Work is done.", + }), + ]; + expect(nativeRunEventsToTranscript(streamed)).toEqual([ + expect.objectContaining({ + kind: "assistant", itemId: "acpx-assistant-initial", + text: "Initial work is active.", delta: true, channel: "progress", + }), + expect.objectContaining({ + kind: "assistant", itemId: "acpx-assistant-final", + text: "Work is done.", delta: true, channel: "progress", + }), + ]); + expect(nativeRunEventsToTranscript([ + ...streamed, + event(3, "item.completed", { + itemId: "acpx-assistant-final", + kind: "agentMessage", + channel: "final", + text: "Work is done.", + }), + ])).toEqual([ + expect.objectContaining({ + kind: "assistant", itemId: "acpx-assistant-initial", + text: "Initial work is active.", delta: true, channel: "progress", + }), + expect.objectContaining({ + kind: "assistant", itemId: "acpx-assistant-final", + text: "Work is done.", channel: "final", + }), + ]); + }); + + it("uses replacement text only for the message it replaces", () => { + expect(nativeRunEventsToTranscript([ + event(1, "item.delta", { + itemId: "initial", kind: "agentMessage", channel: "progress", text: "Initial work.", + }), + event(2, "item.delta", { + itemId: "final", kind: "agentMessage", channel: "progress", text: "Draft answer.", + }), + event(3, "item.completed", { + itemId: "final", kind: "agentMessage", channel: "final", text: "Corrected answer.", + }), + ])).toEqual([ + expect.objectContaining({ kind: "assistant", itemId: "initial", text: "Initial work." }), + expect.objectContaining({ kind: "assistant", itemId: "final", text: "Corrected answer." }), + ]); + }); + it("streams canonical kind-less deltas using item identity from item.started", () => { expect(nativeRunEventsToTranscript([ itemEvent(1, "item.started", "message-1", {