@tickernelz/paperclip-pro-adapter-utils 2026.1005.10 → 2026.1006.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/acpx-engine/ephemeral-session-environment.d.ts +4 -0
- package/dist/acpx-engine/ephemeral-session-environment.d.ts.map +1 -0
- package/dist/acpx-engine/ephemeral-session-environment.js +29 -0
- package/dist/acpx-engine/ephemeral-session-environment.js.map +1 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.d.ts +2 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.d.ts.map +1 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.js +177 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.js.map +1 -0
- package/dist/acpx-engine/execute.d.ts +2 -6
- package/dist/acpx-engine/execute.d.ts.map +1 -1
- package/dist/acpx-engine/execute.js +125 -62
- package/dist/acpx-engine/execute.js.map +1 -1
- package/dist/acpx-engine/execute.test.js +299 -3
- package/dist/acpx-engine/execute.test.js.map +1 -1
- package/dist/acpx-engine/session-codec.d.ts.map +1 -1
- package/dist/acpx-engine/session-codec.js +1 -0
- package/dist/acpx-engine/session-codec.js.map +1 -1
- package/dist/acpx-engine/settlement-characterization.test.js +105 -0
- package/dist/acpx-engine/settlement-characterization.test.js.map +1 -1
- package/dist/acpx-engine/spawn-smoke.test.js +16 -7
- package/dist/acpx-engine/spawn-smoke.test.js.map +1 -1
- package/dist/acpx-engine/startup-cancellation.d.ts +1 -0
- package/dist/acpx-engine/startup-cancellation.d.ts.map +1 -1
- package/dist/acpx-engine/startup-cancellation.js +7 -2
- package/dist/acpx-engine/startup-cancellation.js.map +1 -1
- package/dist/acpx-engine/terminal-session-failure.d.ts +18 -0
- package/dist/acpx-engine/terminal-session-failure.d.ts.map +1 -0
- package/dist/acpx-engine/terminal-session-failure.js +106 -0
- package/dist/acpx-engine/terminal-session-failure.js.map +1 -0
- package/dist/acpx-engine/terminal-session-failure.test.d.ts +2 -0
- package/dist/acpx-engine/terminal-session-failure.test.d.ts.map +1 -0
- package/dist/acpx-engine/terminal-session-failure.test.js +139 -0
- package/dist/acpx-engine/terminal-session-failure.test.js.map +1 -0
- package/dist/acpx-engine/turn-characterization.test.js +4 -3
- package/dist/acpx-engine/turn-characterization.test.js.map +1 -1
- package/dist/command-managed-runtime.d.ts +2 -0
- package/dist/command-managed-runtime.d.ts.map +1 -1
- package/dist/command-managed-runtime.js +2 -0
- package/dist/command-managed-runtime.js.map +1 -1
- package/dist/command-redaction.d.ts +2 -0
- package/dist/command-redaction.d.ts.map +1 -1
- package/dist/command-redaction.js +20 -10
- package/dist/command-redaction.js.map +1 -1
- package/dist/command-redaction.test.js +20 -1
- package/dist/command-redaction.test.js.map +1 -1
- package/dist/directory-merge-lock.test.d.ts +2 -0
- package/dist/directory-merge-lock.test.d.ts.map +1 -0
- package/dist/directory-merge-lock.test.js +166 -0
- package/dist/directory-merge-lock.test.js.map +1 -0
- package/dist/execution-phase.d.ts +10 -0
- package/dist/execution-phase.d.ts.map +1 -0
- package/dist/execution-phase.js +27 -0
- package/dist/execution-phase.js.map +1 -0
- package/dist/execution-phase.test.d.ts +2 -0
- package/dist/execution-phase.test.d.ts.map +1 -0
- package/dist/execution-phase.test.js +26 -0
- package/dist/execution-phase.test.js.map +1 -0
- package/dist/execution-target-sandbox.test.js +49 -7
- package/dist/execution-target-sandbox.test.js.map +1 -1
- package/dist/execution-target-stdin-race.test.js +245 -2
- package/dist/execution-target-stdin-race.test.js.map +1 -1
- package/dist/execution-target.d.ts +6 -0
- package/dist/execution-target.d.ts.map +1 -1
- package/dist/execution-target.js +74 -22
- package/dist/execution-target.js.map +1 -1
- package/dist/git-workspace-sync.d.ts +15 -5
- package/dist/git-workspace-sync.d.ts.map +1 -1
- package/dist/git-workspace-sync.js +267 -124
- package/dist/git-workspace-sync.js.map +1 -1
- package/dist/git-workspace-sync.test.js +296 -20
- package/dist/git-workspace-sync.test.js.map +1 -1
- package/dist/github-launcher.d.ts.map +1 -1
- package/dist/github-launcher.js +26 -11
- package/dist/github-launcher.js.map +1 -1
- package/dist/github-launcher.test.js +34 -1
- package/dist/github-launcher.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/paperclip-runner-permissions.d.ts +28 -1
- package/dist/paperclip-runner-permissions.d.ts.map +1 -1
- package/dist/paperclip-runner-permissions.js +9 -1
- package/dist/paperclip-runner-permissions.js.map +1 -1
- package/dist/prompt-sections.test.d.ts +2 -0
- package/dist/prompt-sections.test.d.ts.map +1 -0
- package/dist/prompt-sections.test.js +53 -0
- package/dist/prompt-sections.test.js.map +1 -0
- package/dist/provider-stopped-boundary.d.ts +7 -0
- package/dist/provider-stopped-boundary.d.ts.map +1 -0
- package/dist/provider-stopped-boundary.js +22 -0
- package/dist/provider-stopped-boundary.js.map +1 -0
- package/dist/provider-stopped-boundary.test.d.ts +2 -0
- package/dist/provider-stopped-boundary.test.d.ts.map +1 -0
- package/dist/provider-stopped-boundary.test.js +76 -0
- package/dist/provider-stopped-boundary.test.js.map +1 -0
- package/dist/remote-managed-runtime.d.ts +2 -0
- package/dist/remote-managed-runtime.d.ts.map +1 -1
- package/dist/remote-managed-runtime.js +3 -1
- package/dist/remote-managed-runtime.js.map +1 -1
- package/dist/remote-managed-runtime.test.js +25 -0
- package/dist/remote-managed-runtime.test.js.map +1 -1
- package/dist/sandbox-callback-bridge.d.ts +2 -0
- package/dist/sandbox-callback-bridge.d.ts.map +1 -1
- package/dist/sandbox-callback-bridge.js +34 -10
- package/dist/sandbox-callback-bridge.js.map +1 -1
- package/dist/sandbox-callback-bridge.test.js +106 -3
- package/dist/sandbox-callback-bridge.test.js.map +1 -1
- package/dist/sandbox-managed-runtime.d.ts +3 -0
- package/dist/sandbox-managed-runtime.d.ts.map +1 -1
- package/dist/sandbox-managed-runtime.js +749 -718
- package/dist/sandbox-managed-runtime.js.map +1 -1
- package/dist/sandbox-managed-runtime.test.js +171 -2
- package/dist/sandbox-managed-runtime.test.js.map +1 -1
- package/dist/server-utils.d.ts +43 -1
- package/dist/server-utils.d.ts.map +1 -1
- package/dist/server-utils.js +103 -31
- package/dist/server-utils.js.map +1 -1
- package/dist/server-utils.test.js +235 -45
- package/dist/server-utils.test.js.map +1 -1
- package/dist/session-compaction.d.ts.map +1 -1
- package/dist/session-compaction.js +6 -0
- package/dist/session-compaction.js.map +1 -1
- package/dist/ssh-fixture.test.js +22 -0
- package/dist/ssh-fixture.test.js.map +1 -1
- package/dist/ssh.d.ts +2 -0
- package/dist/ssh.d.ts.map +1 -1
- package/dist/ssh.js +2 -2
- package/dist/ssh.js.map +1 -1
- package/dist/test-fixtures/prompt-context.d.ts +54 -0
- package/dist/test-fixtures/prompt-context.d.ts.map +1 -0
- package/dist/test-fixtures/prompt-context.js +55 -0
- package/dist/test-fixtures/prompt-context.js.map +1 -0
- package/dist/types.d.ts +12 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/workspace-git-stream.d.ts +19 -0
- package/dist/workspace-git-stream.d.ts.map +1 -0
- package/dist/workspace-git-stream.js +99 -0
- package/dist/workspace-git-stream.js.map +1 -0
- package/dist/workspace-git-stream.test.d.ts +2 -0
- package/dist/workspace-git-stream.test.d.ts.map +1 -0
- package/dist/workspace-git-stream.test.js +71 -0
- package/dist/workspace-git-stream.test.js.map +1 -0
- package/dist/workspace-manifest.d.ts +61 -0
- package/dist/workspace-manifest.d.ts.map +1 -0
- package/dist/workspace-manifest.js +257 -0
- package/dist/workspace-manifest.js.map +1 -0
- package/dist/workspace-manifest.test.d.ts +2 -0
- package/dist/workspace-manifest.test.d.ts.map +1 -0
- package/dist/workspace-manifest.test.js +160 -0
- package/dist/workspace-manifest.test.js.map +1 -0
- package/dist/workspace-restore-diagnostics.d.ts +34 -0
- package/dist/workspace-restore-diagnostics.d.ts.map +1 -0
- package/dist/workspace-restore-diagnostics.js +238 -0
- package/dist/workspace-restore-diagnostics.js.map +1 -0
- package/dist/workspace-restore-diagnostics.test.d.ts +2 -0
- package/dist/workspace-restore-diagnostics.test.d.ts.map +1 -0
- package/dist/workspace-restore-diagnostics.test.js +258 -0
- package/dist/workspace-restore-diagnostics.test.js.map +1 -0
- package/dist/workspace-restore-merge.d.ts +47 -6
- package/dist/workspace-restore-merge.d.ts.map +1 -1
- package/dist/workspace-restore-merge.js +382 -114
- package/dist/workspace-restore-merge.js.map +1 -1
- package/dist/workspace-restore-merge.test.js +253 -4
- package/dist/workspace-restore-merge.test.js.map +1 -1
- package/dist/workspace-restore-result.d.ts +6 -0
- package/dist/workspace-restore-result.d.ts.map +1 -0
- package/dist/workspace-restore-result.js +72 -0
- package/dist/workspace-restore-result.js.map +1 -0
- package/dist/workspace-restore-result.test.d.ts +2 -0
- package/dist/workspace-restore-result.test.d.ts.map +1 -0
- package/dist/workspace-restore-result.test.js +74 -0
- package/dist/workspace-restore-result.test.js.map +1 -0
- package/dist/workspace-restore-teardown.d.ts.map +1 -1
- package/dist/workspace-restore-teardown.js +5 -3
- package/dist/workspace-restore-teardown.js.map +1 -1
- package/dist/workspace-restore-teardown.test.js +24 -0
- package/dist/workspace-restore-teardown.test.js.map +1 -1
- package/node_modules/acpx/dist/live-checkpoint-ClPCSdrW.js +4 -3
- package/package.json +2 -2
|
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { createRuntimeStore } from "acpx/runtime";
|
|
5
6
|
import { DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, prepareAdapterExecutionTargetRuntime, startAdapterExecutionTargetPaperclipBridge, startAdapterExecutionTargetProcessSessionBridge, } from "@tickernelz/paperclip-pro-adapter-utils/execution-target";
|
|
6
7
|
// Wrap the staging seam + both sandbox bridges in call-recording spies that
|
|
7
8
|
// still delegate to the real implementations (a runner-backed sandbox test
|
|
@@ -19,8 +20,12 @@ vi.mock("@tickernelz/paperclip-pro-adapter-utils/execution-target", async (impor
|
|
|
19
20
|
});
|
|
20
21
|
import { buildAcpxRunSummary, createAcpxEngineExecutor, findAncestorBin, geminiVersionSupportsNativeAcpFlag, parseGeminiVersionParts, referencedSourceContentSignature, rewriteGeminiAcpFlagForVersion, summarizeAcpxTurnUsage, } from "./execute.js";
|
|
21
22
|
import { ACPX_HANDSHAKE_TIMEOUT_MS } from "./constants.js";
|
|
23
|
+
import { sessionCodec } from "./session-codec.js";
|
|
22
24
|
import { runChildProcess } from "../server-utils.js";
|
|
25
|
+
import { createPromptContextFixture } from "../test-fixtures/prompt-context.js";
|
|
23
26
|
import { setExpensiveWorkspaceGitExecutor } from "../git-workspace-sync.js";
|
|
27
|
+
import { createWorkspaceRestoreTeardown } from "../workspace-restore-teardown.js";
|
|
28
|
+
import { withWorkspaceRestoreDiagnostics, withWorkspaceRestoreStep } from "../workspace-restore-diagnostics.js";
|
|
24
29
|
import { resolveReferencedSourceIgnore } from "../sandbox-managed-runtime.js";
|
|
25
30
|
import { getActiveStepContext, runWithRuntimeParent, SANDBOX_STARTUP_SPAN_ATTRS, } from "./startup-timing.js";
|
|
26
31
|
const tempRoots = [];
|
|
@@ -265,6 +270,43 @@ const ALLOWED_TURN_SPAN_ATTRIBUTE_KEYS = new Set([
|
|
|
265
270
|
"paperclip.agent.turn.wall_ms",
|
|
266
271
|
]);
|
|
267
272
|
describe("shared ACPX engine runtime behavior", () => {
|
|
273
|
+
it("integrates the env-free store while delivering current credentials and options to the provider", async () => {
|
|
274
|
+
const root = await makeTempRoot();
|
|
275
|
+
const stateDir = path.join(root, "state");
|
|
276
|
+
const first = await runExecutor({ agent: "claude", agentCommand: "node ./fake-acp.js", cwd: root, stateDir,
|
|
277
|
+
env: { ANTHROPIC_API_KEY: "first-fixture-credential" } });
|
|
278
|
+
const liveEnvironment = first.sessionInputs[0].sessionOptions.env;
|
|
279
|
+
expect(liveEnvironment.ANTHROPIC_API_KEY).toBe("first-fixture-credential");
|
|
280
|
+
const store = first.runtimeOptions[0].sessionStore;
|
|
281
|
+
const record = {
|
|
282
|
+
schema: "acpx.session.v1", acpxRecordId: "integration-record", acpSessionId: "integration-session",
|
|
283
|
+
agentCommand: "fixture-provider", cwd: root, name: "fixture", createdAt: "2026-10-03T00:00:00.000Z",
|
|
284
|
+
lastUsedAt: "2026-10-03T00:00:01.000Z", lastSeq: 0,
|
|
285
|
+
eventLog: { active_path: path.join(stateDir, "fixture.jsonl"), segment_count: 1, max_segment_bytes: 1024, max_segments: 2 },
|
|
286
|
+
messages: [], updated_at: "2026-10-03T00:00:01.000Z", cumulative_token_usage: {}, request_token_usage: {},
|
|
287
|
+
acpx: { session_options: { env: { ...liveEnvironment }, model: "retained-model", max_turns: 4 } },
|
|
288
|
+
};
|
|
289
|
+
await store.save(record);
|
|
290
|
+
const file = path.join(stateDir, "sessions", "integration-record.json");
|
|
291
|
+
const saved = await fs.readFile(file, "utf8");
|
|
292
|
+
expect(saved).not.toContain("first-fixture-credential");
|
|
293
|
+
expect(JSON.parse(saved).acpx.session_options).toEqual({ model: "retained-model", max_turns: 4 });
|
|
294
|
+
expect(record.acpx?.session_options?.env).toEqual(liveEnvironment);
|
|
295
|
+
// A file written by the prior engine is loaded with the current run's env,
|
|
296
|
+
// without altering the provider launch or deleting unrelated options.
|
|
297
|
+
record.acpx.session_options.env = { ANTHROPIC_API_KEY: "legacy-fixture-credential" };
|
|
298
|
+
await createRuntimeStore({ stateDir }).save(record);
|
|
299
|
+
const second = await runExecutor({ agent: "claude", agentCommand: "node ./fake-acp.js", cwd: root, stateDir,
|
|
300
|
+
env: { ANTHROPIC_API_KEY: "rotated-fixture-credential" } }, { runtime: { sessionParams: first.result.sessionParams } });
|
|
301
|
+
expect(second.sessionInputs[0].sessionOptions.env.ANTHROPIC_API_KEY).toBe("rotated-fixture-credential");
|
|
302
|
+
const rotatedStore = second.runtimeOptions[0].sessionStore;
|
|
303
|
+
const loaded = await rotatedStore.load(record.acpxRecordId);
|
|
304
|
+
expect(loaded?.acpx?.session_options).toMatchObject({ env: { ANTHROPIC_API_KEY: "rotated-fixture-credential" }, model: "retained-model", max_turns: 4 });
|
|
305
|
+
await rotatedStore.save(loaded);
|
|
306
|
+
const resaved = await fs.readFile(file, "utf8");
|
|
307
|
+
for (const value of ["first-fixture-credential", "legacy-fixture-credential", "rotated-fixture-credential"])
|
|
308
|
+
expect(resaved).not.toContain(value);
|
|
309
|
+
});
|
|
268
310
|
it.each(["claude", "codex", "gemini", "kimi", "custom"])("defaults the legacy %s engine to full auto on fresh and resumed runs", async (agent) => {
|
|
269
311
|
const root = await makeTempRoot();
|
|
270
312
|
const config = {
|
|
@@ -477,6 +519,42 @@ describe("shared ACPX engine runtime behavior", () => {
|
|
|
477
519
|
expect((meta[0]?.env).CODEX_CONFIG).toBeUndefined();
|
|
478
520
|
expect(configOptions).toEqual([]);
|
|
479
521
|
});
|
|
522
|
+
it.each(["claude", "codex", "gemini", "kimi", "custom"])("delivers the shared owned sections at the %s ACP turn boundary", async (agent) => {
|
|
523
|
+
const root = await makeTempRoot();
|
|
524
|
+
const context = createPromptContextFixture();
|
|
525
|
+
const config = { agent, agentCommand: "node ./fixture-acp.js", cwd: root, stateDir: path.join(root, "state"), mode: "persistent" };
|
|
526
|
+
const fresh = await runExecutor(config, { context });
|
|
527
|
+
expect(fresh.turnInputs).toHaveLength(1);
|
|
528
|
+
const prompt = String(fresh.turnInputs[0]?.text ?? "");
|
|
529
|
+
expect(prompt).toContain("You are agent agent-1");
|
|
530
|
+
expect(prompt).toContain("Connection tools:");
|
|
531
|
+
expect(prompt).toContain("Execution contract:");
|
|
532
|
+
expect(prompt).toContain(context.paperclipTaskMarkdownAssignment);
|
|
533
|
+
expect(prompt).toContain(context.paperclipTaskCommunicationGuidance);
|
|
534
|
+
expect(prompt).not.toContain('"objective":');
|
|
535
|
+
expect(prompt).not.toContain("### Issue description");
|
|
536
|
+
expect(prompt).toContain('"id":"comment-first"');
|
|
537
|
+
expect(prompt).toContain('"id":"comment-second"');
|
|
538
|
+
expect(prompt).toContain('"id":"comment-scope"');
|
|
539
|
+
expect(prompt.indexOf('"id":"comment-first"')).toBeLessThan(prompt.indexOf('"id":"comment-second"'));
|
|
540
|
+
expect(prompt).toContain("Untrusted continuation evidence");
|
|
541
|
+
expect(prompt).toContain("receipt-1");
|
|
542
|
+
const resumed = await runExecutor(config, { context, runtime: { sessionParams: fresh.result.sessionParams } });
|
|
543
|
+
expect(resumed.sessionInputs[0]?.resumeSessionId).toBe(fresh.result.sessionId);
|
|
544
|
+
const resumedPrompt = String(resumed.turnInputs[0]?.text ?? "");
|
|
545
|
+
expect(resumedPrompt).not.toContain("Execution contract:");
|
|
546
|
+
expect(resumedPrompt).toContain(context.paperclipTaskMarkdownAssignmentCompact);
|
|
547
|
+
expect(resumedPrompt).not.toContain(context.paperclipTaskCommunicationGuidance);
|
|
548
|
+
expect(resumedPrompt).not.toContain('"id":"comment-first"');
|
|
549
|
+
expect(resumedPrompt).toContain('"id":"comment-second"');
|
|
550
|
+
const reset = await runExecutor(config, { context });
|
|
551
|
+
expect(reset.sessionInputs[0]?.resumeSessionId).toBeUndefined();
|
|
552
|
+
const resetPrompt = String(reset.turnInputs[0]?.text ?? "");
|
|
553
|
+
expect(resetPrompt).toContain("You are agent agent-1");
|
|
554
|
+
expect(resetPrompt).toContain("Connection tools:");
|
|
555
|
+
expect(resetPrompt).toContain(context.paperclipTaskMarkdownAssignment);
|
|
556
|
+
expect(resetPrompt).toContain("Execution contract:");
|
|
557
|
+
});
|
|
480
558
|
it("includes Paperclip env and API access notes in the ACPX prompt without leaking the token", async () => {
|
|
481
559
|
const { meta } = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js" }, {
|
|
482
560
|
authToken: "runtime-secret-token",
|
|
@@ -603,8 +681,8 @@ describe("shared ACPX engine runtime behavior", () => {
|
|
|
603
681
|
expect(prompt).not.toContain("Create child issues");
|
|
604
682
|
expect(prompt).not.toContain("Use child issues");
|
|
605
683
|
}
|
|
606
|
-
expect(String(fresh.meta[0]?.prompt)).toContain(custom ? "Custom agent instructions." : "
|
|
607
|
-
expect(String(reset.meta[0]?.prompt)).toContain(custom ? "Custom agent instructions." : "
|
|
684
|
+
expect(String(fresh.meta[0]?.prompt)).toContain(custom ? "Custom agent instructions." : "You are agent agent-1");
|
|
685
|
+
expect(String(reset.meta[0]?.prompt)).toContain(custom ? "Custom agent instructions." : "You are agent agent-1");
|
|
608
686
|
const ordinary = await runExecutor({ ...config, promptTemplate: "" }, { context: { ...context, conversationMode: false } });
|
|
609
687
|
expect(String(ordinary.meta[0]?.prompt)).toContain("Execution contract:");
|
|
610
688
|
expect(String(ordinary.meta[0]?.prompt)).toContain("Create child issues from the approved plan");
|
|
@@ -2298,6 +2376,23 @@ describe("shared ACPX engine runtime behavior", () => {
|
|
|
2298
2376
|
expect(second.result.sessionParams?.configFingerprint).toBeTypeOf("string");
|
|
2299
2377
|
expect(first.result.sessionParams?.configFingerprint).not.toBe(second.result.sessionParams?.configFingerprint);
|
|
2300
2378
|
});
|
|
2379
|
+
it.each(["claude", "codex", "grok", "gemini", "kimi"])("resumes %s with added and removed MCP servers and current credentials", async (agent) => {
|
|
2380
|
+
const root = await makeTempRoot();
|
|
2381
|
+
const config = { agent, cwd: root, stateDir: path.join(root, "state"), paperclipRuntimeSkills: [], paperclipSkillSync: { desiredSkills: [] } };
|
|
2382
|
+
const first = await runExecutor(config);
|
|
2383
|
+
const server = { name: "github", url: "https://example.test/github/mcp", connectionId: "github", token: "current-token" };
|
|
2384
|
+
const second = await runExecutor(config, {
|
|
2385
|
+
runtime: { sessionParams: sessionCodec.deserialize(first.result.sessionParams), taskKey: "default" },
|
|
2386
|
+
runtimeMcp: { getServers: () => [server] }, context: { refreshTools: true },
|
|
2387
|
+
});
|
|
2388
|
+
expect(second.sessionInputs[0]?.resumeSessionId).toBe("backend-session");
|
|
2389
|
+
expect(second.result.sessionParams?.configFingerprint).toBe(first.result.sessionParams?.configFingerprint);
|
|
2390
|
+
expect(second.runtimeOptions[0]?.mcpServers).toEqual([{ type: "http", name: "github", url: server.url, headers: [{ name: "Authorization", value: "Bearer current-token" }] }]);
|
|
2391
|
+
expect(JSON.stringify(sessionCodec.serialize(second.result.sessionParams ?? null))).not.toContain("current-token");
|
|
2392
|
+
const third = await runExecutor(config, { runtime: { sessionParams: sessionCodec.deserialize(second.result.sessionParams) }, context: { refreshTools: true } });
|
|
2393
|
+
expect(third.sessionInputs[0]?.resumeSessionId).toBe("backend-session");
|
|
2394
|
+
expect(third.runtimeOptions[0]?.mcpServers).toEqual([]);
|
|
2395
|
+
});
|
|
2301
2396
|
it("injects runtime MCP servers and fingerprints their identity without persisting bearer tokens", async () => {
|
|
2302
2397
|
const root = await makeTempRoot();
|
|
2303
2398
|
const baseConfig = {
|
|
@@ -2487,10 +2582,14 @@ describe("gemini ACP flag selection", () => {
|
|
|
2487
2582
|
// an explicit 0 cannot mean "no timeout" — it keeps the 4h backstop.
|
|
2488
2583
|
const explicitZero = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: 0 }, sandboxContext);
|
|
2489
2584
|
expect(explicitZero.runtimeOptions[0]?.timeoutMs).toBe(DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC * 1000);
|
|
2585
|
+
expect(explicitZero.result.resultJson?.adapterExecutionTimeout).toEqual({
|
|
2586
|
+
timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default",
|
|
2587
|
+
});
|
|
2490
2588
|
// A negative timeoutSec is the documented opt-out from any adapter
|
|
2491
2589
|
// wall-clock timeout, sandbox targets included.
|
|
2492
2590
|
const negativeOptOut = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: -1 }, sandboxContext);
|
|
2493
2591
|
expect(negativeOptOut.runtimeOptions[0]?.timeoutMs).toBeUndefined();
|
|
2592
|
+
expect(negativeOptOut.result.resultJson?.adapterExecutionTimeout).toEqual({ timeoutSec: 0, source: "configured" });
|
|
2494
2593
|
const startLine = negativeOptOut.logs.find((entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"));
|
|
2495
2594
|
expect(startLine.text).toContain("Adapter execution timeout: none (explicitly disabled via adapterConfig.timeoutSec; " +
|
|
2496
2595
|
"set it to a positive value to add one).");
|
|
@@ -2546,10 +2645,89 @@ describe("gemini ACP flag selection", () => {
|
|
|
2546
2645
|
"Set adapterConfig.timeoutSec to raise it.";
|
|
2547
2646
|
expect(result.timedOut).toBe(true);
|
|
2548
2647
|
expect(result.errorCode).toBe("acpx_timeout");
|
|
2648
|
+
expect(result.resultJson?.adapterExecutionTimeout).toEqual({ timeoutSec: 1, source: "configured" });
|
|
2549
2649
|
expect(result.errorMessage).toBe(expectedMessage);
|
|
2550
2650
|
expect(cancelReasons).toContain(expectedMessage);
|
|
2651
|
+
expect(result.resultJson).toMatchObject({ acpObservedEventCount: 0, acpPendingToolCount: 0, acpToolInventoryComplete: true });
|
|
2652
|
+
expect(result.resultJson).not.toHaveProperty("acpLastEventAgeMs");
|
|
2551
2653
|
}, 15_000);
|
|
2552
2654
|
});
|
|
2655
|
+
describe("ACP activity diagnostics", () => {
|
|
2656
|
+
it("classifies rejected definitions before redaction and overrides a transient adapter label", async () => {
|
|
2657
|
+
const root = await makeTempRoot(), cwd = path.join(root, "worktree");
|
|
2658
|
+
await fs.mkdir(cwd, { recursive: true });
|
|
2659
|
+
const execute = createAcpxEngineExecutor({
|
|
2660
|
+
classifyTerminalSessionFailure: () => ({ errorCode: "claude_transient_upstream", errorFamily: "transient_upstream" }),
|
|
2661
|
+
createRuntime: () => ({ ...buildRuntime(), startTurn: (options) => {
|
|
2662
|
+
options.onTerminalSessionFailure({ category: "service", details: "API Error: 400 tools.17.custom.name: String should have at most 128 characters" });
|
|
2663
|
+
return { events: (async function* () { })(), result: Promise.resolve({ status: "failed", error: new Error("turn failed") }), cancel: async () => { } };
|
|
2664
|
+
} }),
|
|
2665
|
+
});
|
|
2666
|
+
const result = await execute({ runId: "invalid-definition", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
|
2667
|
+
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd, env: { SECRET: "128" } },
|
|
2668
|
+
context: {}, onMeta: async () => { }, onLog: async () => { } });
|
|
2669
|
+
expect(result).toMatchObject({ errorCode: "provider_tool_definition_invalid", errorFamily: "configuration" });
|
|
2670
|
+
expect(JSON.stringify(result.resultJson?.terminalSessionFailure)).not.toContain("128");
|
|
2671
|
+
});
|
|
2672
|
+
it.each(["terminal", "relay_error", "no_events"])("snapshots %s activity before usage reads, failure logging, and cleanup", async (outcome) => {
|
|
2673
|
+
const root = await makeTempRoot();
|
|
2674
|
+
const cwd = path.join(root, "worktree");
|
|
2675
|
+
await fs.mkdir(cwd, { recursive: true });
|
|
2676
|
+
let currentNow = 1000;
|
|
2677
|
+
let statusReads = 0;
|
|
2678
|
+
const execute = createAcpxEngineExecutor({
|
|
2679
|
+
now: () => currentNow,
|
|
2680
|
+
createRuntime: () => ({
|
|
2681
|
+
...buildRuntime(),
|
|
2682
|
+
getStatus: async () => {
|
|
2683
|
+
if (++statusReads > 1)
|
|
2684
|
+
currentNow += 100_000;
|
|
2685
|
+
return null;
|
|
2686
|
+
},
|
|
2687
|
+
startTurn: () => ({
|
|
2688
|
+
events: (async function* () {
|
|
2689
|
+
if (outcome !== "no_events") {
|
|
2690
|
+
yield { type: "tool_call", toolCallId: "private-pending-id", title: "private command", kind: "execute", status: "in_progress" };
|
|
2691
|
+
yield { type: "tool_call", toolCallId: "private-done-id", title: "private read", kind: "read", status: "in_progress" };
|
|
2692
|
+
yield { type: "tool_call", toolCallId: "private-done-id", status: "completed" };
|
|
2693
|
+
yield { type: "tool_call", toolCallId: "private-cancelled-id", status: "cancelled" };
|
|
2694
|
+
yield { type: "tool_call", toolCallId: "private-failed-id", status: "failed" };
|
|
2695
|
+
yield { type: "status", text: "terminal/create private receipt" };
|
|
2696
|
+
}
|
|
2697
|
+
currentNow = 10_000;
|
|
2698
|
+
if (outcome === "relay_error")
|
|
2699
|
+
throw new Error("relay failed");
|
|
2700
|
+
})(),
|
|
2701
|
+
result: Promise.resolve({ status: "failed", error: new Error("turn failed") }),
|
|
2702
|
+
cancel: async () => { },
|
|
2703
|
+
}),
|
|
2704
|
+
close: async () => { currentNow += 100_000; },
|
|
2705
|
+
}),
|
|
2706
|
+
});
|
|
2707
|
+
const result = await execute({
|
|
2708
|
+
runId: "activity-run", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
|
2709
|
+
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd },
|
|
2710
|
+
context: {}, onMeta: async () => { },
|
|
2711
|
+
onLog: async (_stream, text) => {
|
|
2712
|
+
if (text.includes('"type":"acpx.error"'))
|
|
2713
|
+
currentNow += 100_000;
|
|
2714
|
+
},
|
|
2715
|
+
});
|
|
2716
|
+
expect(result.exitCode).toBe(1);
|
|
2717
|
+
expect(result.resultJson).toMatchObject({
|
|
2718
|
+
acpObservedEventCount: outcome === "no_events" ? 0 : 6,
|
|
2719
|
+
acpPendingToolCount: outcome === "no_events" ? 0 : 1,
|
|
2720
|
+
acpToolInventoryComplete: outcome === "no_events",
|
|
2721
|
+
});
|
|
2722
|
+
if (outcome === "no_events")
|
|
2723
|
+
expect(result.resultJson).not.toHaveProperty("acpLastEventAgeMs");
|
|
2724
|
+
else
|
|
2725
|
+
expect(result.resultJson?.acpLastEventAgeMs).toBe(9000);
|
|
2726
|
+
const diagnostics = Object.fromEntries(Object.entries(result.resultJson ?? {}).filter(([key]) => key.startsWith("acp")));
|
|
2727
|
+
expect(JSON.stringify(diagnostics)).not.toContain("private");
|
|
2728
|
+
expect(currentNow).toBeGreaterThan(10_000);
|
|
2729
|
+
});
|
|
2730
|
+
});
|
|
2553
2731
|
describe("summarizeAcpxTurnUsage", () => {
|
|
2554
2732
|
it("uses the post-turn amount alone when the cumulative cost counter reset", () => {
|
|
2555
2733
|
const summary = summarizeAcpxTurnUsage({
|
|
@@ -3023,6 +3201,28 @@ describe("ACPX engine remote managed-home seam (PR 2: per-adapter home seed)", (
|
|
|
3023
3201
|
expect(remoteAssetDir).not.toBe(managedHomeDir);
|
|
3024
3202
|
expect(path.isAbsolute(remoteAssetDir)).toBe(true);
|
|
3025
3203
|
});
|
|
3204
|
+
it("retains bounded restore diagnostics through real settlement and result reproduction", async () => {
|
|
3205
|
+
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
3206
|
+
const failure = Object.assign(new Error("private-restore-path and command"), { code: 1, stderr: "private-restore-stderr" });
|
|
3207
|
+
const { result } = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, {
|
|
3208
|
+
authToken: "real-run-jwt", executionTarget,
|
|
3209
|
+
prepareRemoteManagedHome: async (input) => {
|
|
3210
|
+
const stagedRuntime = await input.stage([]);
|
|
3211
|
+
return {
|
|
3212
|
+
stagedRuntime,
|
|
3213
|
+
teardown: createWorkspaceRestoreTeardown({
|
|
3214
|
+
stagedRuntime: { restoreWorkspace: () => withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("git_integration", async () => { throw failure; })) },
|
|
3215
|
+
onLog: async () => { }, startMessage: "Restoring workspace", failurePrefix: "Restore failed",
|
|
3216
|
+
}),
|
|
3217
|
+
};
|
|
3218
|
+
},
|
|
3219
|
+
});
|
|
3220
|
+
expect(result.resultJson).toMatchObject({
|
|
3221
|
+
workspaceRestoreFailure: "restore_failed",
|
|
3222
|
+
workspaceRestoreDiagnostic: { phase: "workspace", step: "git_integration", errorCode: "unknown", exitCode: 1 },
|
|
3223
|
+
});
|
|
3224
|
+
expect(JSON.stringify(result)).not.toContain("private-restore-");
|
|
3225
|
+
});
|
|
3026
3226
|
it("test_remote_seam_teardown_fires_once_on_exit", async () => {
|
|
3027
3227
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
3028
3228
|
let teardownCalls = 0;
|
|
@@ -3116,9 +3316,53 @@ describe("ACPX engine Claude skill bundle staging (remote ACP lane)", () => {
|
|
|
3116
3316
|
return { stagedRuntime };
|
|
3117
3317
|
};
|
|
3118
3318
|
}
|
|
3319
|
+
it("advertises folded routing metadata and exact staged paths without injecting skill bodies", async () => {
|
|
3320
|
+
const root = await makeTempRoot();
|
|
3321
|
+
const cwd = path.join(root, "worktree");
|
|
3322
|
+
const stateDir = path.join(root, "state");
|
|
3323
|
+
await fs.mkdir(cwd, { recursive: true });
|
|
3324
|
+
const skill = await createSkill(path.join(root, "sources"), "paperclip--versioned", [
|
|
3325
|
+
"---", "description: >-", " Use for Paperclip-managed tasks", " and durable task documents.",
|
|
3326
|
+
"---", "PRIVATE_SKILL_BODY_RECIPE", "",
|
|
3327
|
+
].join("\n"));
|
|
3328
|
+
const { meta, result } = await runExecutor({
|
|
3329
|
+
agent: "claude", agentCommand: "node ./fake-acp.js", stateDir, cwd,
|
|
3330
|
+
paperclipRuntimeSkills: [skill], paperclipSkillSync: { desiredSkills: [skill.key] },
|
|
3331
|
+
});
|
|
3332
|
+
const bundle = await onlyChildDir(path.join(stateDir, "runtime-skills", "claude"));
|
|
3333
|
+
const stagedFile = path.join(bundle, ".claude", "skills", skill.runtimeName, "SKILL.md");
|
|
3334
|
+
const prompt = String(meta[0]?.prompt ?? "");
|
|
3335
|
+
expect(prompt).toContain(`- ${skill.runtimeName}: Use for Paperclip-managed tasks and durable task documents. (file: ${stagedFile})`);
|
|
3336
|
+
expect(prompt).not.toContain("PRIVATE_SKILL_BODY_RECIPE");
|
|
3337
|
+
expect(prompt).not.toContain(skill.source);
|
|
3338
|
+
expect(result.sessionParams?.skills).toMatchObject({ selectedSkills: [skill.runtimeName] });
|
|
3339
|
+
await expect(fs.readFile(stagedFile, "utf8")).resolves.toContain("PRIVATE_SKILL_BODY_RECIPE");
|
|
3340
|
+
});
|
|
3341
|
+
it("bounds routing descriptions and keeps files discoverable when metadata is absent or malformed", async () => {
|
|
3342
|
+
const root = await makeTempRoot();
|
|
3343
|
+
const cwd = path.join(root, "worktree");
|
|
3344
|
+
const stateDir = path.join(root, "state");
|
|
3345
|
+
await fs.mkdir(cwd, { recursive: true });
|
|
3346
|
+
const sources = path.join(root, "sources");
|
|
3347
|
+
const bounded = await createSkill(sources, "bounded", `---\ndescription: ${"x".repeat(600)}\n---\nBOUNDED_BODY\n`);
|
|
3348
|
+
const absent = await createSkill(sources, "absent", "# ABSENT_BODY\n");
|
|
3349
|
+
const malformed = await createSkill(sources, "malformed", "---\ndescription: incomplete header\nMALFORMED_BODY\n");
|
|
3350
|
+
const skills = [bounded, absent, malformed];
|
|
3351
|
+
const { meta } = await runExecutor({
|
|
3352
|
+
agent: "claude", agentCommand: "node ./fake-acp.js", stateDir, cwd,
|
|
3353
|
+
paperclipRuntimeSkills: skills, paperclipSkillSync: { desiredSkills: skills.map((skill) => skill.key) },
|
|
3354
|
+
});
|
|
3355
|
+
const prompt = String(meta[0]?.prompt ?? "");
|
|
3356
|
+
expect(prompt).toContain(`- bounded: ${"x".repeat(512)} (file: `);
|
|
3357
|
+
expect(prompt).not.toContain("x".repeat(513));
|
|
3358
|
+
expect(prompt).toMatch(/- absent \(file: .*\/absent\/SKILL\.md\)/);
|
|
3359
|
+
expect(prompt).toMatch(/- malformed \(file: .*\/malformed\/SKILL\.md\)/);
|
|
3360
|
+
for (const body of ["BOUNDED_BODY", "ABSENT_BODY", "MALFORMED_BODY"])
|
|
3361
|
+
expect(prompt).not.toContain(body);
|
|
3362
|
+
});
|
|
3119
3363
|
it("rewrites the prompt and skill identity onto the in-sandbox skill root once the bundle is staged", async () => {
|
|
3120
3364
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
3121
|
-
const skill = await createSkill(path.join(localCwd, "skills"), "review");
|
|
3365
|
+
const skill = await createSkill(path.join(localCwd, "skills"), "review", "---\ndescription: Review requested task documents.\n---\n# review\nREMOTE_BODY_ONLY\n");
|
|
3122
3366
|
const { meta, result } = await runExecutor({
|
|
3123
3367
|
agent: "claude",
|
|
3124
3368
|
agentCommand: "node ./fake-acp.js",
|
|
@@ -3134,6 +3378,8 @@ describe("ACPX engine Claude skill bundle staging (remote ACP lane)", () => {
|
|
|
3134
3378
|
expect(prompt).not.toContain(hostSkillsHome);
|
|
3135
3379
|
const inSandboxSkillsRoot = prompt.match(/Skill root: (\S+)/)[1];
|
|
3136
3380
|
expect(inSandboxSkillsRoot).not.toBe(hostSkillsHome);
|
|
3381
|
+
expect(prompt).toContain(`- review: Review requested task documents. (file: ${path.join(inSandboxSkillsRoot, "review", "SKILL.md")})`);
|
|
3382
|
+
expect(prompt).not.toContain("REMOTE_BODY_ONLY");
|
|
3137
3383
|
await expect(fs.readFile(path.join(inSandboxSkillsRoot, skill.runtimeName, "SKILL.md"), "utf8")).resolves.toContain("# review");
|
|
3138
3384
|
const skillsIdentity = result.sessionParams?.skills;
|
|
3139
3385
|
expect(skillsIdentity?.skillRoot).toBe(inSandboxSkillsRoot);
|
|
@@ -5508,10 +5754,29 @@ describe("ACPX engine run lifecycle corrections (F3: one teardown error policy)"
|
|
|
5508
5754
|
...overrides,
|
|
5509
5755
|
};
|
|
5510
5756
|
}
|
|
5757
|
+
it("collects instructions after confirmed close on a thrown provider turn, before workspace restore", async () => {
|
|
5758
|
+
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
5759
|
+
const { paperclipStops, anyStopped } = stubBridges();
|
|
5760
|
+
const order = [];
|
|
5761
|
+
const execute = createAcpxEngineExecutor({
|
|
5762
|
+
stagingLocks: new Map(), warmHandles: new Map(), stagedRuntimes: new Map(),
|
|
5763
|
+
createRuntime: () => ({ ensureSession: async () => okHandle, startTurn: () => throwingTurn(),
|
|
5764
|
+
close: async () => { order.push("close"); } }),
|
|
5765
|
+
});
|
|
5766
|
+
const result = await execute({ runId: "instruction-stop-failure",
|
|
5767
|
+
...remoteArgs(stateDir, localCwd, executionTarget, { onProviderStopped: async () => {
|
|
5768
|
+
expect(anyStopped(paperclipStops)).toBe(true);
|
|
5769
|
+
order.push("collect");
|
|
5770
|
+
} }),
|
|
5771
|
+
});
|
|
5772
|
+
expect(result.exitCode).toBe(1);
|
|
5773
|
+
expect(order).toEqual(["close", "collect"]);
|
|
5774
|
+
});
|
|
5511
5775
|
it("test_teardown_continues_after_one_teardown_step_fails", async () => {
|
|
5512
5776
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
5513
5777
|
const { paperclipStops, processStops, anyStopped } = stubBridges();
|
|
5514
5778
|
const stagingLocks = new Map();
|
|
5779
|
+
const collectInstructions = vi.fn(async () => { });
|
|
5515
5780
|
const logs = [];
|
|
5516
5781
|
const execute = createAcpxEngineExecutor({
|
|
5517
5782
|
stagingLocks,
|
|
@@ -5529,12 +5794,14 @@ describe("ACPX engine run lifecycle corrections (F3: one teardown error policy)"
|
|
|
5529
5794
|
const result = await execute({
|
|
5530
5795
|
runId: "td-continue",
|
|
5531
5796
|
...remoteArgs(stateDir, localCwd, executionTarget, {
|
|
5797
|
+
onProviderStopped: collectInstructions,
|
|
5532
5798
|
onLog: async (stream, text) => {
|
|
5533
5799
|
logs.push({ stream, text });
|
|
5534
5800
|
},
|
|
5535
5801
|
}),
|
|
5536
5802
|
});
|
|
5537
5803
|
expect(result.exitCode).toBe(1);
|
|
5804
|
+
expect(collectInstructions).not.toHaveBeenCalled();
|
|
5538
5805
|
// The close failure did not stop the bridge stops or the lease release.
|
|
5539
5806
|
expect(anyStopped(paperclipStops)).toBe(true);
|
|
5540
5807
|
expect(anyStopped(processStops)).toBe(true);
|
|
@@ -5542,6 +5809,35 @@ describe("ACPX engine run lifecycle corrections (F3: one teardown error policy)"
|
|
|
5542
5809
|
// The close failure was recorded, not silently dropped.
|
|
5543
5810
|
expect(logs.some((entry) => entry.stream === "stderr" && entry.text.includes('teardown step "runtime-close" failed'))).toBe(true);
|
|
5544
5811
|
});
|
|
5812
|
+
it("restores managed home and releases the lease when instruction collection rejects", async () => {
|
|
5813
|
+
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
5814
|
+
const { paperclipStops, processStops, anyStopped } = stubBridges();
|
|
5815
|
+
const stagingLocks = new Map();
|
|
5816
|
+
const order = [];
|
|
5817
|
+
const logs = [];
|
|
5818
|
+
const execute = createAcpxEngineExecutor({
|
|
5819
|
+
stagingLocks, warmHandles: new Map(), stagedRuntimes: new Map(),
|
|
5820
|
+
prepareRemoteManagedHome: async (input) => ({
|
|
5821
|
+
stagedRuntime: await input.stage([]),
|
|
5822
|
+
teardown: async () => { order.push("restore"); return { ok: true }; },
|
|
5823
|
+
}),
|
|
5824
|
+
createRuntime: () => ({ ensureSession: async () => okHandle, startTurn: () => throwingTurn(),
|
|
5825
|
+
close: async () => { order.push("close"); } }),
|
|
5826
|
+
});
|
|
5827
|
+
const result = await execute({ runId: "instruction-collection-reject",
|
|
5828
|
+
...remoteArgs(stateDir, localCwd, executionTarget, {
|
|
5829
|
+
onProviderStopped: async () => { order.push("collect"); throw new Error("collector failed reading /private/test-instructions/AGENTS.md"); },
|
|
5830
|
+
onLog: async (_stream, text) => { logs.push(text); },
|
|
5831
|
+
}),
|
|
5832
|
+
});
|
|
5833
|
+
expect(result.exitCode).toBe(1);
|
|
5834
|
+
expect(order).toEqual(["close", "collect", "restore"]);
|
|
5835
|
+
expect(anyStopped(paperclipStops)).toBe(true);
|
|
5836
|
+
expect(anyStopped(processStops)).toBe(true);
|
|
5837
|
+
expect(stagingLocks.size).toBe(0);
|
|
5838
|
+
expect(logs.some(text => text.includes('teardown step "instruction-collection" failed: Instruction collection failed'))).toBe(true);
|
|
5839
|
+
expect(logs.some(text => text.includes("/private/test-instructions"))).toBe(false);
|
|
5840
|
+
});
|
|
5545
5841
|
it("test_staging_lease_releases_in_finally_on_every_exit_path", async () => {
|
|
5546
5842
|
const scenarios = [
|
|
5547
5843
|
{
|