fix(hermes): project native completion and tool activity

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 01:57:28 -05:00
1 parent 300afb3fe3
commit f628d54bc8
12 files changed
+225 -12

No files matched your search

+41 -2
View File
@@ -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.
@@ -56,3 +56,9 @@ export async function probeAcpxCursorInstallation(model: string): Promise<void>
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<void> {
const installation = await verifyHermesInstallation(resolveQualifiedAcpxProfile("hermes", model));
await (await installation.openCommand()).close();
}
+1 -1
View File
@@ -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";
+35 -2
View File
@@ -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!({
+11 -4
View File
@@ -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.` }],
@@ -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);
@@ -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(
+1
View File
@@ -3,4 +3,5 @@ export {
probeAcpxClaudeInstallation,
probeAcpxGrokInstallation,
probeAcpxCursorInstallation,
probeAcpxHermesInstallation,
} from "@paperclipai/paperclip-runner/live";
@@ -248,14 +248,14 @@ describe("omitProgressRepeatedByResponseAcrossSegments", () => {
});
});
function toolCall(name: string, input?: unknown): TranscriptEntry {
function toolCall(name: string, input?: unknown): Extract<TranscriptEntry, { kind: "tool_call" }> {
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.
@@ -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({
@@ -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 = {
@@ -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" },