mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
fix(hermes): project native completion and tool activity
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
300afb3fe3
commit
f628d54bc8
12 files changed
+225
-12
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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!({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user