@selesai/code 0.8.4 → 0.8.6
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/CHANGELOG.md +22 -0
- package/README.md +7 -1
- package/dist/core/slash-commands.js +1 -0
- package/dist/defaults/models.json +46 -55
- package/dist/defaults/settings.json +7 -8
- package/dist/extensions/agent-browser.test.ts +178 -2
- package/dist/extensions/agent-browser.ts +2 -0
- package/dist/extensions/copy-turn.test.ts +229 -0
- package/dist/extensions/copy-turn.ts +11 -0
- package/dist/extensions/grep-app/index.test.ts +356 -1
- package/dist/extensions/grep-app/index.ts +6 -1
- package/dist/extensions/handoff-new.test.ts +351 -161
- package/dist/extensions/handoff-new.ts +3 -0
- package/dist/extensions/inline-skills.test.ts +93 -0
- package/dist/extensions/inline-skills.ts +3 -0
- package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
- package/dist/extensions/pi-subagents/README.md +2 -0
- package/dist/extensions/pi-subagents/agents/builder.md +5 -2
- package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
- package/dist/extensions/pi-subagents/docs/configuration.md +54 -2
- package/dist/extensions/pi-subagents/docs/observability.md +1 -1
- package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
- package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
- package/dist/extensions/pi-subagents/package-lock.json +2 -2
- package/dist/extensions/pi-subagents/package.json +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
- package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
- package/dist/extensions/pi-subagents/src/extension/config.ts +12 -0
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +104 -14
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
- package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
- package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
- package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +14 -5
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
- package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
- package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
- package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +653 -196
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
- package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
- package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
- package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +108 -0
- package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +226 -13
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
- package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
- package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
- package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
- package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
- package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
- package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
- package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
- package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
- package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
- package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
- package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
- package/dist/extensions/ponytail/index.js +11 -0
- package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
- package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
- package/dist/extensions/ponytail/test/extension.test.js +274 -140
- package/dist/extensions/ponytail/test/helpers.test.js +280 -92
- package/dist/extensions/question/index.ts +18 -0
- package/dist/extensions/question/question-list.ts +22 -0
- package/dist/extensions/question/tests/batch.test.ts +103 -65
- package/dist/extensions/question/tests/helpers.test.ts +68 -35
- package/dist/extensions/question/tests/question-list.test.ts +506 -204
- package/dist/extensions/question/tests/row-layout.test.ts +180 -114
- package/dist/extensions/question/tests/schemas.test.ts +42 -19
- package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
- package/dist/extensions/question/tests/wizard.test.ts +771 -50
- package/dist/extensions/rtk.test.ts +180 -1
- package/dist/extensions/tokenin-onboarding.ts +3 -0
- package/dist/extensions/undo.test.ts +720 -0
- package/dist/extensions/undo.ts +6 -0
- package/dist/extensions/web-agent-onboarding.test.ts +415 -0
- package/dist/extensions/workflow/extension.ts +3 -3
- package/dist/extensions/workflow/modes.ts +41 -19
- package/dist/modes/interactive/interactive-mode.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode.js +48 -1
- package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
- package/docs/plans/workflow-autoloop-reference.md +1 -2
- package/docs/plans/workflow-handoff-carryover.md +108 -0
- package/docs/quickstart.md +27 -43
- package/docs/settings.md +4 -0
- package/docs/usage.md +1 -0
- package/package.json +5 -1
|
@@ -41,6 +41,7 @@ import { ACTIVE_RUN_INDEX_DIR } from "../../src/runs/background/active-run-index
|
|
|
41
41
|
import { CHILD_WATCHDOG_STATUS_EVENT } from "../../src/watchdog/child-status.ts";
|
|
42
42
|
import { WAIT_TOOL_ENABLED_ENV } from "../../src/runs/background/wait-config.ts";
|
|
43
43
|
import { TOOL_BUDGET_ENV, TOOL_BUDGET_ZERO_AUTH_ENV } from "../../src/runs/shared/tool-budget.ts";
|
|
44
|
+
import { createRunFanoutBudget, encodeRunFanoutBudgetDescriptor, RUN_FANOUT_BUDGET_ENV } from "../../src/runs/shared/run-fanout-budget.ts";
|
|
44
45
|
import { MainWatchdogRuntime } from "../../src/watchdog/runtime.ts";
|
|
45
46
|
import { MAX_CHILD_PENDING_LINE_BYTES, MAX_CHILD_STDERR_BYTES } from "../../src/runs/shared/child-protocol.ts";
|
|
46
47
|
import {
|
|
@@ -66,6 +67,7 @@ interface ProgressSummary {
|
|
|
66
67
|
agent: string;
|
|
67
68
|
index: number;
|
|
68
69
|
status: string;
|
|
70
|
+
task?: string;
|
|
69
71
|
activityState?: string;
|
|
70
72
|
lastActivityAt?: number;
|
|
71
73
|
currentTool?: string;
|
|
@@ -79,6 +81,7 @@ interface ProgressSummary {
|
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
interface ArtifactPaths {
|
|
84
|
+
inputPath?: string;
|
|
82
85
|
outputPath: string;
|
|
83
86
|
transcriptPath?: string;
|
|
84
87
|
metadataPath?: string;
|
|
@@ -103,6 +106,7 @@ interface RuntimeAcknowledgedExtensions {
|
|
|
103
106
|
interface RunSyncResult {
|
|
104
107
|
exitCode: number;
|
|
105
108
|
agent: string;
|
|
109
|
+
task?: string;
|
|
106
110
|
messages: unknown[];
|
|
107
111
|
error?: string;
|
|
108
112
|
protocolError?: { code?: string; stream?: string; limitBytes?: number; observedBytes?: number };
|
|
@@ -322,6 +326,13 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
322
326
|
return readCall().args;
|
|
323
327
|
}
|
|
324
328
|
|
|
329
|
+
function readAllCallArgs(): string[][] {
|
|
330
|
+
return fs.readdirSync(mockPi.dir)
|
|
331
|
+
.filter((name) => name.startsWith("call-") && name.endsWith(".json"))
|
|
332
|
+
.sort()
|
|
333
|
+
.map((name) => (JSON.parse(fs.readFileSync(path.join(mockPi.dir, name), "utf-8")) as MockPiCallRecord).args);
|
|
334
|
+
}
|
|
335
|
+
|
|
325
336
|
function makeExecutor(
|
|
326
337
|
agents = [makeAgent("echo")],
|
|
327
338
|
config: Record<string, unknown> = {},
|
|
@@ -387,6 +398,19 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
387
398
|
assert.equal(mockPi.callCount(), 0);
|
|
388
399
|
});
|
|
389
400
|
|
|
401
|
+
it("rejects internal fan-out fields from public workflows", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
402
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
403
|
+
for (const params of [
|
|
404
|
+
{ workflowScript: `return runs.run("main", { agent: "echo", task: "work" })`, runFanoutBudget: { version: 1 } },
|
|
405
|
+
{ workflowScript: `return runs.run("main", { agent: "echo", task: "work" })`, runFanoutAdmitted: true },
|
|
406
|
+
] as const) {
|
|
407
|
+
const result = await executor.executePublic("private-fanout", params, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
408
|
+
assert.equal(result.isError, true);
|
|
409
|
+
assert.match(result.content[0]?.text ?? "", /does not accept internal run fan-out fields/);
|
|
410
|
+
}
|
|
411
|
+
assert.equal(mockPi.callCount(), 0);
|
|
412
|
+
});
|
|
413
|
+
|
|
390
414
|
it("allows schedule.create to carry the required workflowScript target", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
391
415
|
let forwarded;
|
|
392
416
|
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, async (params) => {
|
|
@@ -494,6 +518,105 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
494
518
|
fs.rmSync(resultPath, { force: true });
|
|
495
519
|
});
|
|
496
520
|
|
|
521
|
+
it("runs an external CLI workflow child with subagents.defaultModel configured", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
522
|
+
const markerPath = path.join(tempDir, "external-started");
|
|
523
|
+
const executor = makeExecutor([
|
|
524
|
+
makeAgent("external", {
|
|
525
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "started"); process.stdout.write("external result")`] },
|
|
526
|
+
model: "mock/default-model",
|
|
527
|
+
modelSource: { type: "subagents.defaultModel", scope: "user", path: "/settings.json", model: "mock/default-model" },
|
|
528
|
+
}),
|
|
529
|
+
]);
|
|
530
|
+
const ctx = { ...makeMinimalCtx(tempDir), model: { provider: "mock", id: "parent-model" } };
|
|
531
|
+
const started = await executor.execute(
|
|
532
|
+
`external-workflow-${Date.now()}`,
|
|
533
|
+
{ workflowScript: `return await runs.run("external", { agent: "external", task: "Run external", async: true });` },
|
|
534
|
+
new AbortController().signal,
|
|
535
|
+
undefined,
|
|
536
|
+
ctx,
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
assert.equal(started.isError, undefined);
|
|
540
|
+
assert.ok(started.details.asyncId);
|
|
541
|
+
const workflowResultPath = path.join(DIRS.results, `${started.details.asyncId}.json`);
|
|
542
|
+
let workflowResult: { state?: string; results?: Array<{ output?: string; runId?: string }> } = {};
|
|
543
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
544
|
+
if (fs.existsSync(workflowResultPath)) workflowResult = JSON.parse(fs.readFileSync(workflowResultPath, "utf-8"));
|
|
545
|
+
if (workflowResult.state === "complete" || workflowResult.state === "failed") break;
|
|
546
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
547
|
+
}
|
|
548
|
+
assert.equal(workflowResult.state, "complete");
|
|
549
|
+
assert.match(workflowResult.results?.[0]?.output ?? "", /Async: external/);
|
|
550
|
+
for (let attempt = 0; attempt < 100 && !fs.existsSync(markerPath); attempt++) {
|
|
551
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
552
|
+
}
|
|
553
|
+
assert.equal(fs.readFileSync(markerPath, "utf-8"), "started");
|
|
554
|
+
assert.equal(mockPi.callCount(), 0);
|
|
555
|
+
|
|
556
|
+
fs.rmSync(started.details.asyncDir!, { recursive: true, force: true });
|
|
557
|
+
fs.rmSync(workflowResultPath, { force: true });
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("rejects explicit model overrides for external CLI agents", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
561
|
+
const executor = makeExecutor([
|
|
562
|
+
makeAgent("external", {
|
|
563
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", "process.stdout.write('unreachable')"] },
|
|
564
|
+
}),
|
|
565
|
+
]);
|
|
566
|
+
const result = await executor.execute(
|
|
567
|
+
"external-explicit-model",
|
|
568
|
+
{ agent: "external", task: "Run external", async: true, model: "mock/override" },
|
|
569
|
+
new AbortController().signal,
|
|
570
|
+
undefined,
|
|
571
|
+
makeMinimalCtx(tempDir),
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
assert.equal(result.isError, true);
|
|
575
|
+
assert.match(result.content[0]?.text ?? "", /does not support: model override/);
|
|
576
|
+
assert.equal(mockPi.callCount(), 0);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("rejects external CLI agent models that differ from inherited subagents.defaultModel", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
580
|
+
const executor = makeExecutor([
|
|
581
|
+
makeAgent("external", {
|
|
582
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", "process.stdout.write('unreachable')"] },
|
|
583
|
+
model: "mock/override-model",
|
|
584
|
+
modelSource: { type: "subagents.defaultModel", scope: "user", path: "/settings.json", model: "mock/default-model" },
|
|
585
|
+
}),
|
|
586
|
+
]);
|
|
587
|
+
const result = await executor.execute(
|
|
588
|
+
"external-agent-override-model",
|
|
589
|
+
{ agent: "external", task: "Run external", async: true },
|
|
590
|
+
new AbortController().signal,
|
|
591
|
+
undefined,
|
|
592
|
+
makeMinimalCtx(tempDir),
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
assert.equal(result.isError, true);
|
|
596
|
+
assert.match(result.content[0]?.text ?? "", /does not support: model override/);
|
|
597
|
+
assert.equal(mockPi.callCount(), 0);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("rejects external CLI agent models that equal inherited subagents.defaultModel without provenance", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
601
|
+
const executor = makeExecutor([
|
|
602
|
+
makeAgent("external", {
|
|
603
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", "process.stdout.write('unreachable')"] },
|
|
604
|
+
model: "mock/default-model",
|
|
605
|
+
}),
|
|
606
|
+
]);
|
|
607
|
+
const result = await executor.execute(
|
|
608
|
+
"external-agent-same-value-override-model",
|
|
609
|
+
{ agent: "external", task: "Run external", async: true },
|
|
610
|
+
new AbortController().signal,
|
|
611
|
+
undefined,
|
|
612
|
+
makeMinimalCtx(tempDir),
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
assert.equal(result.isError, true);
|
|
616
|
+
assert.match(result.content[0]?.text ?? "", /does not support: model override/);
|
|
617
|
+
assert.equal(mockPi.callCount(), 0);
|
|
618
|
+
});
|
|
619
|
+
|
|
497
620
|
it("projects live child activity into async workflow status", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
498
621
|
mockPi.onCall({
|
|
499
622
|
steps: [
|
|
@@ -728,9 +851,9 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
728
851
|
assert.equal(childResult.parentWorkflowRunId, workflowRunId);
|
|
729
852
|
assert.equal(childResult.workflowKey, "background");
|
|
730
853
|
assert.equal(fs.existsSync(workflowStepSessionFile), true);
|
|
731
|
-
fs.rmSync(started.details.asyncDir!, { recursive: true, force: true });
|
|
854
|
+
fs.rmSync(started.details.asyncDir!, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
732
855
|
fs.rmSync(workflowResultPath, { force: true });
|
|
733
|
-
fs.rmSync(childDir, { recursive: true, force: true });
|
|
856
|
+
fs.rmSync(childDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
734
857
|
fs.rmSync(path.join(DIRS.results, `${childRunId}.json`), { force: true });
|
|
735
858
|
});
|
|
736
859
|
|
|
@@ -821,7 +944,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
821
944
|
const missionFiles = fs.readdirSync(missionDir).filter((entry) => entry.endsWith(".json"));
|
|
822
945
|
assert.equal(missionFiles.length, 1);
|
|
823
946
|
const mission = JSON.parse(fs.readFileSync(path.join(missionDir, missionFiles[0]!), "utf-8")) as { objective?: string };
|
|
824
|
-
assert.equal(mission.objective,
|
|
947
|
+
assert.equal(mission.objective, utils.PROMPT_REDACTED);
|
|
825
948
|
assert.deepEqual(result.details.workflow?.trace.filter((entry) => entry.state === "completed").map((entry) => entry.key), ["scan", "review"]);
|
|
826
949
|
});
|
|
827
950
|
|
|
@@ -1015,6 +1138,30 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1015
1138
|
assert.deepEqual(result.details.workflow?.trace.filter((entry) => entry.state !== "started").map(({ state }) => state).sort(), ["completed", "failed"]);
|
|
1016
1139
|
});
|
|
1017
1140
|
|
|
1141
|
+
it("rejects an over-limit runs.all batch before launching any workflow child", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1142
|
+
const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerRun: 1 });
|
|
1143
|
+
|
|
1144
|
+
const result = await executor.execute(
|
|
1145
|
+
"scripted-workflow-fanout-limit",
|
|
1146
|
+
{
|
|
1147
|
+
async: false,
|
|
1148
|
+
workflowScript: `return await runs.all([
|
|
1149
|
+
{ key: "first", agent: "echo", task: "First task" },
|
|
1150
|
+
{ key: "second", agent: "echo", task: "Second task" }
|
|
1151
|
+
]);`,
|
|
1152
|
+
},
|
|
1153
|
+
new AbortController().signal,
|
|
1154
|
+
undefined,
|
|
1155
|
+
makeMinimalCtx(tempDir),
|
|
1156
|
+
);
|
|
1157
|
+
|
|
1158
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
1159
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1160
|
+
const children = result.details.workflow?.value as Array<{ ok: boolean; error?: string }>;
|
|
1161
|
+
assert.deepEqual(children.map(({ ok }) => ok), [false, false]);
|
|
1162
|
+
for (const child of children) assert.match(child.error ?? "", /workflow\[second\].*0\/1 used; 2 requested, 1 remaining/);
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1018
1165
|
it("runs a direct child gate as host-verified acceptance", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1019
1166
|
const markerFile = "direct-gate.txt";
|
|
1020
1167
|
const markerPath = path.join(tempDir, markerFile);
|
|
@@ -1779,6 +1926,46 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1779
1926
|
assert.equal(mockPi.callCount(), 1);
|
|
1780
1927
|
});
|
|
1781
1928
|
|
|
1929
|
+
it("qualifies inherited nested claims with the generated nested run id", async () => {
|
|
1930
|
+
mockPi.onCall({ output: "nested completed" });
|
|
1931
|
+
const descriptor = createRunFanoutBudget("root-run", 2);
|
|
1932
|
+
const previous = process.env[RUN_FANOUT_BUDGET_ENV];
|
|
1933
|
+
try {
|
|
1934
|
+
process.env[RUN_FANOUT_BUDGET_ENV] = encodeRunFanoutBudgetDescriptor({ ...descriptor, parentPath: "tasks[0]" });
|
|
1935
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1936
|
+
const result = await executor.execute("nested", { agent: "echo", task: "Nested work" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
1937
|
+
|
|
1938
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "nested run failed");
|
|
1939
|
+
const claims = fs.readdirSync(path.join(descriptor.directory, "claims"));
|
|
1940
|
+
assert.equal(claims.length, 1);
|
|
1941
|
+
const claim = JSON.parse(fs.readFileSync(path.join(descriptor.directory, "claims", claims[0]!), "utf-8")) as { path: string };
|
|
1942
|
+
assert.match(claim.path, /^tasks\[0\]\/[a-f0-9]{8}\/single$/);
|
|
1943
|
+
} finally {
|
|
1944
|
+
if (previous === undefined) delete process.env[RUN_FANOUT_BUDGET_ENV];
|
|
1945
|
+
else process.env[RUN_FANOUT_BUDGET_ENV] = previous;
|
|
1946
|
+
fs.rmSync(descriptor.directory, { recursive: true, force: true });
|
|
1947
|
+
}
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1950
|
+
it("rejects an over-limit static run fan-out before creating session artifacts", async () => {
|
|
1951
|
+
const sessionDir = path.join(tempDir, "run-fanout-preflight");
|
|
1952
|
+
const executor = makeExecutor([makeAgent("echo"), makeAgent("second")], { maxSubagentSpawnsPerRun: 1 });
|
|
1953
|
+
const result = await executor.execute(
|
|
1954
|
+
"run-fanout-preflight",
|
|
1955
|
+
{ tasks: [{ agent: "echo", task: "First" }, { agent: "second", task: "Second" }], sessionDir },
|
|
1956
|
+
new AbortController().signal,
|
|
1957
|
+
undefined,
|
|
1958
|
+
makeMinimalCtx(tempDir),
|
|
1959
|
+
);
|
|
1960
|
+
|
|
1961
|
+
assert.equal(result.isError, true);
|
|
1962
|
+
assert.match(result.content[0]?.text ?? "", /Run fan-out limit reached at tasks\[1\] \(0\/1 used; 2 requested, 1 remaining\)/);
|
|
1963
|
+
assert.deepEqual(result.details.runFanoutBudget, { used: 0, limit: 1, remaining: 1 });
|
|
1964
|
+
assert.equal(result.details.runFanoutRejection?.path, "tasks[1]");
|
|
1965
|
+
assert.equal(fs.existsSync(sessionDir), false);
|
|
1966
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1782
1969
|
it("reports structured spawn-budget usage through status", async () => {
|
|
1783
1970
|
const spawnState = { sessionId: "session-123", count: 3, configuredLimit: 4, granted: 1, grantHistory: [] };
|
|
1784
1971
|
const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 4 }, false, spawnState);
|
|
@@ -2457,12 +2644,15 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2457
2644
|
assert.match(result.content[0]?.text ?? "", /Implemented/);
|
|
2458
2645
|
});
|
|
2459
2646
|
|
|
2460
|
-
it("returns error for unknown agent", async () => {
|
|
2647
|
+
it("returns error for unknown agent without retaining the prompt", async () => {
|
|
2461
2648
|
const agents = makeAgentConfigs(["echo"]);
|
|
2462
|
-
const
|
|
2649
|
+
const sentinel = "PROMPT_AUDIT_SENTINEL_UNKNOWN";
|
|
2650
|
+
const result = await runSync(tempDir, agents, "nonexistent", sentinel, {});
|
|
2463
2651
|
|
|
2464
2652
|
assert.equal(result.exitCode, 1);
|
|
2465
2653
|
assert.ok(result.error?.includes("Unknown agent"));
|
|
2654
|
+
assert.equal(result.task, "[prompt redacted]");
|
|
2655
|
+
assert.doesNotMatch(JSON.stringify(result), new RegExp(sentinel));
|
|
2466
2656
|
});
|
|
2467
2657
|
|
|
2468
2658
|
|
|
@@ -2578,6 +2768,29 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2578
2768
|
assert.equal(mockPi.callCount(), 2);
|
|
2579
2769
|
});
|
|
2580
2770
|
|
|
2771
|
+
it("escalates to file task delivery after a zero-activity SIGKILL startup exit", { skip: process.platform === "win32" ? "POSIX child signal reporting is unavailable on Windows" : false }, async () => {
|
|
2772
|
+
mockPi.onCall({ signal: "SIGKILL" });
|
|
2773
|
+
mockPi.onCall({ output: "Recovered via file delivery" });
|
|
2774
|
+
const agents = [makeAgent("worker", { model: "openai/gpt-5-mini" })];
|
|
2775
|
+
|
|
2776
|
+
const result = await runSync(tempDir, agents, "worker", "Do work", {
|
|
2777
|
+
runId: "startup-sigkill-file-delivery-sync",
|
|
2778
|
+
acceptance: false,
|
|
2779
|
+
});
|
|
2780
|
+
|
|
2781
|
+
assert.equal(result.exitCode, 0);
|
|
2782
|
+
assert.equal(result.finalOutput, "Recovered via file delivery");
|
|
2783
|
+
assert.equal(mockPi.callCount(), 2);
|
|
2784
|
+
const [firstArgs, retryArgs] = readAllCallArgs();
|
|
2785
|
+
assert.ok(firstArgs?.includes("Task: Do work"), "first attempt should deliver the task inline");
|
|
2786
|
+
const retryTaskArg = retryArgs?.at(-1) ?? "";
|
|
2787
|
+
assert.ok(
|
|
2788
|
+
retryTaskArg.startsWith("@") && retryTaskArg.endsWith("task.md"),
|
|
2789
|
+
`retry should reference a task file instead of inline argv, got: ${retryTaskArg}`,
|
|
2790
|
+
);
|
|
2791
|
+
assert.match(result.progress.recentOutput.join("\n"), /\[startup-retry\].*file task delivery/i);
|
|
2792
|
+
});
|
|
2793
|
+
|
|
2581
2794
|
it("does not retry a non-zero exit after tool activity", async () => {
|
|
2582
2795
|
mockPi.onCall({ jsonl: [events.toolStart("read", { path: "package.json" })], exitCode: 1 });
|
|
2583
2796
|
mockPi.onCall({ output: "must not run" });
|
|
@@ -2981,7 +3194,9 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2981
3194
|
|
|
2982
3195
|
assert.equal(result.exitCode, 1);
|
|
2983
3196
|
assert.match(result.error ?? "", /429 quota exceeded/);
|
|
2984
|
-
|
|
3197
|
+
// No fallback model configured: the last candidate is retried on the same
|
|
3198
|
+
// model up to MODEL_RETRY_DELAYS_MS.length times before giving up.
|
|
3199
|
+
assert.deepEqual(result.modelAttempts?.map((attempt) => attempt.success), [false, false, false, false]);
|
|
2985
3200
|
});
|
|
2986
3201
|
|
|
2987
3202
|
it("treats recovered child tool errors as successful foreground runs", async () => {
|
|
@@ -3089,7 +3304,9 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3089
3304
|
});
|
|
3090
3305
|
|
|
3091
3306
|
assert.equal(result.exitCode, 1);
|
|
3092
|
-
|
|
3307
|
+
// Primary fails, fallback fails, then the last (fallback) candidate is
|
|
3308
|
+
// retried on the same model up to MODEL_RETRY_DELAYS_MS.length times.
|
|
3309
|
+
assert.deepEqual(result.modelAttempts?.map((attempt) => attempt.success), [false, false, false, false, false]);
|
|
3093
3310
|
assert.match(result.error ?? "", /429 quota exceeded/);
|
|
3094
3311
|
});
|
|
3095
3312
|
|
|
@@ -3341,7 +3558,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3341
3558
|
assert.equal(mockPi.callCount(), 0);
|
|
3342
3559
|
});
|
|
3343
3560
|
|
|
3344
|
-
it("writes artifacts
|
|
3561
|
+
it("writes artifacts without retaining the effective prompt", async () => {
|
|
3345
3562
|
mockPi.onCall({
|
|
3346
3563
|
output: "Result text",
|
|
3347
3564
|
runtimeAcknowledgedExtensions: { version: 1, source: "child-runtime", ids: ["ext.ok"], omitted: 0 },
|
|
@@ -3349,8 +3566,9 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3349
3566
|
const privateExtension = path.join(tempDir, "extensions", "private-extension.ts");
|
|
3350
3567
|
const agents = [makeAgent("echo", { extensions: [privateExtension] })];
|
|
3351
3568
|
const artifactsDir = path.join(tempDir, "artifacts");
|
|
3569
|
+
const sentinel = "PROMPT_AUDIT_SENTINEL_1021";
|
|
3352
3570
|
|
|
3353
|
-
const result = await runSync(tempDir, agents, "echo",
|
|
3571
|
+
const result = await runSync(tempDir, agents, "echo", sentinel, {
|
|
3354
3572
|
runId: "test-run",
|
|
3355
3573
|
artifactsDir,
|
|
3356
3574
|
artifactConfig: { enabled: true, includeInput: true, includeOutput: true, includeMetadata: true },
|
|
@@ -3358,16 +3576,27 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3358
3576
|
|
|
3359
3577
|
assert.equal(result.exitCode, 0);
|
|
3360
3578
|
assert.ok(result.artifactPaths, "should have artifact paths");
|
|
3579
|
+
assert.ok(result.artifactPaths.inputPath, "should have a redacted input artifact");
|
|
3580
|
+
assert.doesNotMatch(fs.readFileSync(result.artifactPaths.inputPath, "utf-8"), new RegExp(sentinel));
|
|
3581
|
+
assert.match(fs.readFileSync(result.artifactPaths.inputPath, "utf-8"), /live Prompt Audit only/);
|
|
3361
3582
|
assert.ok(result.transcriptPath, "should expose transcript path on the result");
|
|
3362
3583
|
assert.equal(result.transcriptPath, result.artifactPaths.transcriptPath);
|
|
3363
3584
|
assert.ok(fs.existsSync(result.transcriptPath), "transcript should be written");
|
|
3364
3585
|
const transcript = fs.readFileSync(result.transcriptPath, "utf-8").trim().split("\n").map((line) => JSON.parse(line) as { recordType?: string; source?: string; text?: string });
|
|
3365
3586
|
assert.equal(transcript[0]?.recordType, "message");
|
|
3366
3587
|
assert.equal(transcript[0]?.source, "foreground");
|
|
3588
|
+
assert.match(transcript[0]?.text ?? "", /live Prompt Audit only/);
|
|
3589
|
+
assert.doesNotMatch(fs.readFileSync(result.transcriptPath, "utf-8"), new RegExp(sentinel));
|
|
3367
3590
|
assert.match(transcript.at(-1)?.text ?? "", /^Result text/);
|
|
3368
3591
|
assert.equal(result.transcriptError, undefined);
|
|
3369
3592
|
assert.ok(fs.existsSync(artifactsDir), "artifacts dir should exist");
|
|
3370
|
-
const
|
|
3593
|
+
const metadataText = fs.readFileSync(result.artifactPaths.metadataPath, "utf-8");
|
|
3594
|
+
const metadata = JSON.parse(metadataText) as { task?: string; launchContractDigest?: string; launchResolvedExtensions?: LaunchResolvedExtensions; runtimeAcknowledgedExtensions?: RuntimeAcknowledgedExtensions };
|
|
3595
|
+
assert.doesNotMatch(metadataText, new RegExp(sentinel));
|
|
3596
|
+
assert.equal(metadata.task, "[prompt redacted]");
|
|
3597
|
+
assert.equal(result.task, "[prompt redacted]");
|
|
3598
|
+
assert.equal(result.progress.task, "[prompt redacted]");
|
|
3599
|
+
assert.match(readCallArgs().join("\n"), new RegExp(sentinel));
|
|
3371
3600
|
assert.equal(metadata.launchContractDigest, result.launchContractDigest);
|
|
3372
3601
|
assert.equal(result.launchResolvedExtensions?.source, "launch-resolved");
|
|
3373
3602
|
assert.equal(result.launchResolvedExtensions?.disableAmbientExtensions, true);
|
|
@@ -3756,6 +3985,81 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3756
3985
|
assert.equal(agentResult.details?.timeoutMs, 4_000);
|
|
3757
3986
|
});
|
|
3758
3987
|
|
|
3988
|
+
it("threads the global config timeout default from deps.config, without overriding explicit or agent values", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3989
|
+
const NINETY_MIN = 90 * 60 * 1000;
|
|
3990
|
+
mockPi.onCall({ output: "config default" });
|
|
3991
|
+
mockPi.onCall({ output: "explicit over config" });
|
|
3992
|
+
mockPi.onCall({ output: "agent over config" });
|
|
3993
|
+
mockPi.onCall({ output: "invalid config ignored" });
|
|
3994
|
+
|
|
3995
|
+
// A global config.timeoutMs replaces the built-in 30-minute foreground backstop.
|
|
3996
|
+
const configExecutor = makeExecutor([makeAgent("echo")], { timeoutMs: NINETY_MIN });
|
|
3997
|
+
const configResult = await configExecutor.execute(
|
|
3998
|
+
"config-timeout-default",
|
|
3999
|
+
{ agent: "echo", task: "Task", async: false },
|
|
4000
|
+
new AbortController().signal,
|
|
4001
|
+
undefined,
|
|
4002
|
+
makeMinimalCtx(tempDir),
|
|
4003
|
+
);
|
|
4004
|
+
assert.equal(configResult.details?.timeoutMs, NINETY_MIN);
|
|
4005
|
+
|
|
4006
|
+
// An explicit call value still wins over the global config default.
|
|
4007
|
+
const explicitResult = await configExecutor.execute(
|
|
4008
|
+
"config-timeout-explicit",
|
|
4009
|
+
{ agent: "echo", task: "Task", async: false, timeoutMs: 2_000 },
|
|
4010
|
+
new AbortController().signal,
|
|
4011
|
+
undefined,
|
|
4012
|
+
makeMinimalCtx(tempDir),
|
|
4013
|
+
);
|
|
4014
|
+
assert.equal(explicitResult.details?.timeoutMs, 2_000);
|
|
4015
|
+
|
|
4016
|
+
// An agent frontmatter default still wins over the global config default (single launches).
|
|
4017
|
+
const agentResult = await makeExecutor([makeAgent("echo", { defaultTimeoutMs: 4_000 })], { timeoutMs: NINETY_MIN }).execute(
|
|
4018
|
+
"config-timeout-agent-default",
|
|
4019
|
+
{ agent: "echo", task: "Task", async: false },
|
|
4020
|
+
new AbortController().signal,
|
|
4021
|
+
undefined,
|
|
4022
|
+
makeMinimalCtx(tempDir),
|
|
4023
|
+
);
|
|
4024
|
+
assert.equal(agentResult.details?.timeoutMs, 4_000);
|
|
4025
|
+
|
|
4026
|
+
// An invalid config value is ignored -> falls back to the built-in 30-minute default.
|
|
4027
|
+
const invalidResult = await makeExecutor([makeAgent("echo")], { timeoutMs: -1 }).execute(
|
|
4028
|
+
"config-timeout-invalid",
|
|
4029
|
+
{ agent: "echo", task: "Task", async: false },
|
|
4030
|
+
new AbortController().signal,
|
|
4031
|
+
undefined,
|
|
4032
|
+
makeMinimalCtx(tempDir),
|
|
4033
|
+
);
|
|
4034
|
+
assert.equal(invalidResult.details?.timeoutMs, executorMod?.DEFAULT_FOREGROUND_TIMEOUT_MS);
|
|
4035
|
+
});
|
|
4036
|
+
|
|
4037
|
+
it("applies the global config timeout default to foreground workflow scripts", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
4038
|
+
mockPi.onCall({ delay: 5_000, output: "too late" });
|
|
4039
|
+
mockPi.onCall({ delay: 5_000, output: "too late" });
|
|
4040
|
+
const executor = makeExecutor([makeAgent("echo")], { timeoutMs: 250 });
|
|
4041
|
+
|
|
4042
|
+
const configResult = await executor.execute(
|
|
4043
|
+
"workflow-config-timeout-default",
|
|
4044
|
+
{ async: false, workflowScript: `return await runs.run("slow", { agent: "echo", task: "Wait" });` },
|
|
4045
|
+
new AbortController().signal,
|
|
4046
|
+
undefined,
|
|
4047
|
+
makeMinimalCtx(tempDir),
|
|
4048
|
+
);
|
|
4049
|
+
assert.equal(configResult.isError, true);
|
|
4050
|
+
assert.match(configResult.content[0]?.text ?? "", /Workflow script timed out after 250ms/);
|
|
4051
|
+
|
|
4052
|
+
const explicitResult = await executor.execute(
|
|
4053
|
+
"workflow-config-timeout-explicit",
|
|
4054
|
+
{ async: false, timeoutMs: 150, workflowScript: `return await runs.run("slow", { agent: "echo", task: "Wait" });` },
|
|
4055
|
+
new AbortController().signal,
|
|
4056
|
+
undefined,
|
|
4057
|
+
makeMinimalCtx(tempDir),
|
|
4058
|
+
);
|
|
4059
|
+
assert.equal(explicitResult.isError, true);
|
|
4060
|
+
assert.match(explicitResult.content[0]?.text ?? "", /Workflow script timed out after 150ms/);
|
|
4061
|
+
});
|
|
4062
|
+
|
|
3759
4063
|
it("runs omitted async launches in the background when the global default is enabled", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3760
4064
|
const executor = makeExecutor([makeAgent("echo")], {}, true);
|
|
3761
4065
|
|
|
@@ -245,6 +245,7 @@ describe("acceptance gates", () => {
|
|
|
245
245
|
assert.match(prompt, /Patch the bug/);
|
|
246
246
|
assert.match(prompt, /```acceptance-report/);
|
|
247
247
|
assert.match(prompt, /array fields contain strings/);
|
|
248
|
+
assert.match(prompt, /empty-string entr(?:y|ies).*\[\s*""\s*\]/i);
|
|
248
249
|
assert.match(prompt, /criteriaSatisfied\[\]\.status.*satisfied, not-satisfied, not-applicable/);
|
|
249
250
|
assert.match(prompt, /commandsRun\[\]\.result.*passed, failed, not-run/);
|
|
250
251
|
assert.match(prompt, /manualNotes.*optional strings.*empty string.*does not satisfy.*manual-notes/);
|
|
@@ -482,7 +483,7 @@ describe("acceptance gates", () => {
|
|
|
482
483
|
[{ criteriaSatisfied: [{ id: "criterion-1", status: "maybe", evidence: "proof" }] }, /criteriaSatisfied\[0\]\.status.*got "maybe"/],
|
|
483
484
|
[{ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "proof", confidence: 1 }] }, /criteriaSatisfied\[0\]\.confidence: unsupported acceptance criterion field/],
|
|
484
485
|
[{ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "proof" }, { id: "Criterion_1", status: "satisfied", evidence: "proof" }] }, /duplicate normalized criterion id 'criterion-1'/],
|
|
485
|
-
[{ reviewFindings: [
|
|
486
|
+
[{ reviewFindings: [{}] }, /reviewFindings\[0\]: expected non-empty string; got object/],
|
|
486
487
|
[{ noStagedFiles: "yes" }, /noStagedFiles: expected boolean; got "yes"/],
|
|
487
488
|
] as const) {
|
|
488
489
|
const parsed = parseAcceptanceReport(report(overrides));
|
|
@@ -491,6 +492,35 @@ describe("acceptance gates", () => {
|
|
|
491
492
|
}
|
|
492
493
|
});
|
|
493
494
|
|
|
495
|
+
it("tolerates empty-string entries in string-array fields by dropping them at parse time", () => {
|
|
496
|
+
// A model writing ["\"\""] for "no items" must not fail the whole report.
|
|
497
|
+
for (const field of ["changedFiles", "testsAddedOrUpdated", "validationOutput", "residualRisks", "reviewFindings"] as const) {
|
|
498
|
+
const parsed = parseAcceptanceReport(report({ [field]: [""] }));
|
|
499
|
+
assert.equal(parsed.error, undefined, `${field}: ["\"\""] should parse`);
|
|
500
|
+
assert.deepEqual(parsed.report?.[field], [], `${field}: empty-string entries dropped`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Mixed arrays keep only meaningful entries.
|
|
504
|
+
const mixed = parseAcceptanceReport(report({ reviewFindings: ["blocker: file.ts:12", ""] }));
|
|
505
|
+
assert.equal(mixed.error, undefined);
|
|
506
|
+
assert.deepEqual(mixed.report?.reviewFindings, ["blocker: file.ts:12"]);
|
|
507
|
+
|
|
508
|
+
// Whitespace-only entries are treated as empty.
|
|
509
|
+
const whitespace = parseAcceptanceReport(report({ validationOutput: [" "] }));
|
|
510
|
+
assert.equal(whitespace.error, undefined);
|
|
511
|
+
assert.deepEqual(whitespace.report?.validationOutput, []);
|
|
512
|
+
|
|
513
|
+
// A single string is still coerced to a one-item array.
|
|
514
|
+
const coerced = parseAcceptanceReport(report({ changedFiles: "src/file.ts" }));
|
|
515
|
+
assert.equal(coerced.error, undefined);
|
|
516
|
+
assert.deepEqual(coerced.report?.changedFiles, ["src/file.ts"]);
|
|
517
|
+
|
|
518
|
+
// Structural garbage (non-string entries) is still rejected.
|
|
519
|
+
const invalid = parseAcceptanceReport(report({ reviewFindings: [{}] }));
|
|
520
|
+
assert.equal(invalid.report, undefined);
|
|
521
|
+
assert.match(invalid.error ?? "", /reviewFindings\[0\]: expected non-empty string; got object/);
|
|
522
|
+
});
|
|
523
|
+
|
|
494
524
|
it("accepts empty optional note strings without treating them as evidence", async () => {
|
|
495
525
|
for (const field of ["manualNotes", "notes"] as const) {
|
|
496
526
|
const parsed = parseAcceptanceReport(report({ notes: undefined, [field]: "" }));
|