@selesai/code 0.5.20 → 0.5.21
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/dist/extensions/handoff-new.test.ts +8 -9
- package/dist/extensions/handoff-new.ts +6 -8
- package/dist/extensions/pi-subagents/CHANGELOG.md +1367 -0
- package/dist/extensions/pi-subagents/README.md +602 -220
- package/dist/extensions/pi-subagents/banner.png +0 -0
- package/dist/extensions/pi-subagents/index.ts +1 -0
- package/dist/extensions/pi-subagents/install.mjs +3 -3
- package/dist/extensions/pi-subagents/package-lock.json +3405 -0
- package/dist/extensions/pi-subagents/package.json +26 -14
- package/dist/extensions/pi-subagents/prompts/gather-context-and-clarify.md +1 -1
- package/dist/extensions/pi-subagents/prompts/parallel-cleanup.md +9 -9
- package/dist/extensions/pi-subagents/prompts/parallel-context-build.md +3 -3
- package/dist/extensions/pi-subagents/prompts/parallel-handoff-plan.md +7 -7
- package/dist/extensions/pi-subagents/prompts/parallel-research.md +6 -6
- package/dist/extensions/pi-subagents/prompts/parallel-review.md +8 -8
- package/dist/extensions/pi-subagents/prompts/review-loop.md +13 -11
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +267 -176
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +89 -16
- package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +19 -0
- package/dist/extensions/pi-subagents/src/agents/agents.ts +218 -120
- package/dist/extensions/pi-subagents/src/agents/frontmatter.ts +67 -13
- package/dist/extensions/pi-subagents/src/agents/proactive-skills.ts +2 -2
- package/dist/extensions/pi-subagents/src/agents/skills.ts +25 -12
- package/dist/extensions/pi-subagents/src/api/background-work.ts +197 -0
- package/dist/extensions/pi-subagents/src/api/capability-ceiling.ts +17 -0
- package/dist/extensions/pi-subagents/src/api/delegation.ts +285 -0
- package/dist/extensions/pi-subagents/src/api/preflight.ts +399 -0
- package/dist/extensions/pi-subagents/src/extension/chain-validation.ts +165 -0
- package/dist/extensions/pi-subagents/src/extension/config.ts +7 -1
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +15 -0
- package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +3 -1
- package/dist/extensions/pi-subagents/src/extension/index.ts +113 -157
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +41 -4
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +45 -15
- package/dist/extensions/pi-subagents/src/extension/steering-notices.ts +35 -0
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +28 -15
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -3
- package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +225 -29
- package/dist/extensions/pi-subagents/src/intercom/result-intercom.ts +11 -0
- package/dist/extensions/pi-subagents/src/profiles/profiles.ts +8 -10
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +452 -62
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +60 -9
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +198 -52
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +119 -21
- package/dist/extensions/pi-subagents/src/runs/background/auto-drain.ts +67 -0
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +16 -8
- package/dist/extensions/pi-subagents/src/runs/background/completion-batcher.ts +6 -4
- package/dist/extensions/pi-subagents/src/runs/background/completion-dedupe.ts +2 -11
- package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +260 -13
- package/dist/extensions/pi-subagents/src/runs/background/fleet-view.ts +32 -6
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +171 -90
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +280 -0
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +149 -88
- package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +14 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +33 -17
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +9 -1
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +38 -10
- package/dist/extensions/pi-subagents/src/runs/background/steering.ts +237 -0
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +1297 -307
- package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +610 -0
- package/dist/extensions/pi-subagents/src/runs/background/top-level-async.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/background/wait-config.ts +36 -0
- package/dist/extensions/pi-subagents/src/runs/background/wait-tool.ts +26 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/async-steering-action.ts +230 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-clarify.ts +22 -6
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +228 -140
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +452 -138
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +90 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +1001 -416
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +443 -132
- package/dist/extensions/pi-subagents/src/runs/shared/agent-contract.ts +38 -0
- package/dist/extensions/pi-subagents/src/runs/shared/capability-ceiling.ts +178 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-protocol.ts +121 -0
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +41 -93
- package/dist/extensions/pi-subagents/src/runs/shared/context-mode.ts +44 -0
- package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +11 -9
- package/dist/extensions/pi-subagents/src/runs/shared/long-running-guard.ts +4 -0
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +36 -0
- package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +44 -7
- package/dist/extensions/pi-subagents/src/runs/shared/nested-render.ts +4 -1
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-handoff.ts +154 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +18 -0
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +174 -54
- package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +38 -31
- package/dist/extensions/pi-subagents/src/runs/shared/run-history.ts +90 -5
- package/dist/extensions/pi-subagents/src/runs/shared/session-lease.ts +299 -0
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +61 -6
- package/dist/extensions/pi-subagents/src/runs/shared/spawn-budget.ts +128 -0
- package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +112 -7
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -6
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +149 -36
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +181 -0
- package/dist/extensions/pi-subagents/src/runs/shared/tool-availability.ts +83 -0
- package/dist/extensions/pi-subagents/src/runs/shared/tool-budget.ts +11 -5
- package/dist/extensions/pi-subagents/src/runs/shared/turn-budget.ts +50 -4
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +63 -14
- package/dist/extensions/pi-subagents/src/shared/accessible-dir.ts +25 -0
- package/dist/extensions/pi-subagents/src/shared/artifacts.ts +37 -7
- package/dist/extensions/pi-subagents/src/shared/atomic-json.ts +18 -43
- package/dist/extensions/pi-subagents/src/shared/child-transcript.ts +52 -0
- package/dist/extensions/pi-subagents/src/shared/env.ts +16 -0
- package/dist/extensions/pi-subagents/src/shared/file-system-retry.ts +47 -0
- package/dist/extensions/pi-subagents/src/shared/fork-context.ts +28 -3
- package/dist/extensions/pi-subagents/src/shared/launch-contract.ts +123 -0
- package/dist/extensions/pi-subagents/src/shared/model-info.ts +8 -5
- package/dist/extensions/pi-subagents/src/shared/settings.ts +9 -1
- package/dist/extensions/pi-subagents/src/shared/status-format.ts +7 -1
- package/dist/extensions/pi-subagents/src/shared/types.ts +522 -51
- package/dist/extensions/pi-subagents/src/shared/utils.ts +59 -53
- package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +604 -0
- package/dist/extensions/pi-subagents/src/slash/delegation-json.ts +108 -0
- package/dist/extensions/pi-subagents/src/slash/delegation-request.ts +249 -0
- package/dist/extensions/pi-subagents/src/slash/prompt-template-bridge.ts +353 -345
- package/dist/extensions/pi-subagents/src/slash/prompt-workflows.ts +2 -2
- package/dist/extensions/pi-subagents/src/slash/selector.ts +147 -0
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +265 -23
- package/dist/extensions/pi-subagents/src/slash/slash-live-state.ts +2 -2
- package/dist/extensions/pi-subagents/src/slash/subagents-admin.ts +428 -0
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +362 -0
- package/dist/extensions/pi-subagents/src/tui/fleet-transcript.ts +472 -0
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +664 -0
- package/dist/extensions/pi-subagents/src/tui/render.ts +115 -31
- package/dist/extensions/pi-subagents/src/watchdog/change-signature.ts +220 -0
- package/dist/extensions/pi-subagents/src/watchdog/child-status.ts +205 -0
- package/dist/extensions/pi-subagents/src/watchdog/emission-guard.ts +123 -0
- package/dist/extensions/pi-subagents/src/watchdog/lsp-diagnostics.ts +532 -0
- package/dist/extensions/pi-subagents/src/watchdog/model-selection.ts +167 -0
- package/dist/extensions/pi-subagents/src/watchdog/register-child.ts +117 -0
- package/dist/extensions/pi-subagents/src/watchdog/register-main.ts +433 -0
- package/dist/extensions/pi-subagents/src/watchdog/render.ts +54 -0
- package/dist/extensions/pi-subagents/src/watchdog/review.ts +298 -0
- package/dist/extensions/pi-subagents/src/watchdog/runtime.ts +714 -0
- package/dist/extensions/pi-subagents/src/watchdog/settings.ts +528 -0
- package/dist/extensions/pi-subagents/src/watchdog/tool-actions.ts +155 -0
- package/dist/extensions/pi-subagents/src/watchdog/turn-delta.ts +161 -0
- package/dist/extensions/pi-subagents/src/watchdog/types.ts +188 -0
- package/dist/extensions/pi-subagents/src/watchdog/warning-format.ts +73 -0
- package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +130 -2
- package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +439 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +1632 -61
- package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +54 -1
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +112 -0
- package/dist/extensions/pi-subagents/test/integration/chain-clarify.test.ts +98 -1
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +179 -9
- package/dist/extensions/pi-subagents/test/integration/detect-error.test.ts +16 -9
- package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +6 -5
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +589 -9
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +528 -21
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +227 -3
- package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +235 -7
- package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +172 -2
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +1502 -18
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +993 -4
- package/dist/extensions/pi-subagents/test/integration/slash-live-state.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/integration/top-level-async.test.ts +7 -1
- package/dist/extensions/pi-subagents/test/support/helpers.ts +32 -0
- package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +59 -2
- package/dist/extensions/pi-subagents/test/support/mock-pi.ts +32 -6
- package/dist/extensions/pi-subagents/test/support/real-session-child-cli.mjs +25 -24
- package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +59 -35
- package/dist/extensions/pi-subagents/test/support/session-lease-child.mjs +42 -0
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +703 -69
- package/dist/extensions/pi-subagents/test/unit/accessible-dir.test.ts +82 -0
- package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +15 -15
- package/dist/extensions/pi-subagents/test/unit/agent-eject-disable.test.ts +66 -66
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +478 -19
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +228 -7
- package/dist/extensions/pi-subagents/test/unit/agent-memory.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +198 -78
- package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +15 -1
- package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +102 -7
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +439 -6
- package/dist/extensions/pi-subagents/test/unit/atomic-json.test.ts +41 -1
- package/dist/extensions/pi-subagents/test/unit/auto-drain.test.ts +79 -0
- package/dist/extensions/pi-subagents/test/unit/background-work.test.ts +285 -0
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling-pi-args.test.ts +84 -0
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling.test.ts +68 -0
- package/dist/extensions/pi-subagents/test/unit/chain-validation.test.ts +326 -0
- package/dist/extensions/pi-subagents/test/unit/child-protocol.test.ts +82 -0
- package/dist/extensions/pi-subagents/test/unit/child-transcript.test.ts +46 -4
- package/dist/extensions/pi-subagents/test/unit/completion-batcher.test.ts +3 -1
- package/dist/extensions/pi-subagents/test/unit/completion-dedupe.test.ts +9 -17
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +175 -3
- package/dist/extensions/pi-subagents/test/unit/config-dir-runtime.test.ts +10 -5
- package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +126 -6
- package/dist/extensions/pi-subagents/test/unit/default-extensions.test.ts +219 -0
- package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +913 -0
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +13 -2
- package/dist/extensions/pi-subagents/test/unit/dynamic-fanout.test.ts +3 -3
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +351 -0
- package/dist/extensions/pi-subagents/test/unit/fleet-transcript.test.ts +280 -0
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +575 -0
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +65 -0
- package/dist/extensions/pi-subagents/test/unit/fork-context.test.ts +94 -1
- package/dist/extensions/pi-subagents/test/unit/get-final-output.test.ts +23 -0
- package/dist/extensions/pi-subagents/test/unit/host-peer-runtime-imports.test.ts +157 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +367 -5
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +11 -0
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +36 -0
- package/dist/extensions/pi-subagents/test/unit/model-info.test.ts +25 -3
- package/dist/extensions/pi-subagents/test/unit/model-scope.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/native-supervisor-channel.test.ts +341 -10
- package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +16 -0
- package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +52 -0
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +168 -7
- package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +85 -7
- package/dist/extensions/pi-subagents/test/unit/parallel-handoff.test.ts +175 -0
- package/dist/extensions/pi-subagents/test/unit/path-resolution.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +243 -16
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +79 -36
- package/dist/extensions/pi-subagents/test/unit/pi-spawn.test.ts +174 -56
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +322 -0
- package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +204 -0
- package/dist/extensions/pi-subagents/test/unit/recursion-guard.test.ts +20 -12
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +117 -4
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +40 -7
- package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +48 -1
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +21 -3
- package/dist/extensions/pi-subagents/test/unit/selector.test.ts +57 -0
- package/dist/extensions/pi-subagents/test/unit/session-lease.test.ts +326 -0
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +101 -2
- package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +111 -1
- package/dist/extensions/pi-subagents/test/unit/spawn-budget.test.ts +124 -0
- package/dist/extensions/pi-subagents/test/unit/stale-run-reconciler.test.ts +6 -4
- package/dist/extensions/pi-subagents/test/unit/status-format.test.ts +7 -0
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +397 -0
- package/dist/extensions/pi-subagents/test/unit/steering-notices.test.ts +58 -0
- package/dist/extensions/pi-subagents/test/unit/steering.test.ts +173 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-control.test.ts +25 -4
- package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +390 -25
- package/dist/extensions/pi-subagents/test/unit/subagent-wait.test.ts +837 -0
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +86 -0
- package/dist/extensions/pi-subagents/test/unit/tool-budget.test.ts +15 -0
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +47 -1
- package/dist/extensions/pi-subagents/test/unit/turn-budget.test.ts +56 -10
- package/dist/extensions/pi-subagents/test/unit/watchdog-change-signature.test.ts +387 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-child-status.test.ts +110 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-emission-guard.test.ts +62 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-lsp-diagnostics.test.ts +144 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-model-selection.test.ts +121 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-render.test.ts +76 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-review.test.ts +327 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-runtime.test.ts +863 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-settings.test.ts +290 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-tool-actions.test.ts +91 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-turn-delta.test.ts +130 -0
- package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +32 -4
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +5 -1
- package/dist/extensions/pi-subagents/test/unit/writer-budget-guidance.test.ts +30 -0
- package/dist/extensions/question/batch.ts +39 -0
- package/dist/extensions/question/dialog-adapter.ts +23 -1
- package/dist/extensions/question/index.ts +237 -7
- package/dist/extensions/question/schemas.ts +28 -15
- package/dist/extensions/question/tests/batch.test.ts +26 -0
- package/dist/extensions/question/tests/ui-protocol.test.ts +23 -3
- package/dist/extensions/question/tui-adapter.ts +2 -3
- package/dist/extensions/question/types.ts +37 -0
- package/dist/extensions/question/ui-protocol.ts +3 -3
- package/dist/skills/batch-grill-me/SKILL.md +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { initTheme } from "@selesai/code";
|
|
5
|
+
import { SelectorComponent } from "../../src/slash/selector.ts";
|
|
6
|
+
|
|
7
|
+
initTheme("dark");
|
|
8
|
+
|
|
9
|
+
function createHarness(items: Array<{ value: string; label: string; current?: boolean }>) {
|
|
10
|
+
const results: Array<{ confirmed: boolean; value?: string }> = [];
|
|
11
|
+
const component = new SelectorComponent(
|
|
12
|
+
{ requestRender() {} } as never,
|
|
13
|
+
{
|
|
14
|
+
fg(_color: string, text: string) { return text; },
|
|
15
|
+
bold(text: string) { return text; },
|
|
16
|
+
} as never,
|
|
17
|
+
{
|
|
18
|
+
matches(key: string, binding: string) { return key === binding; },
|
|
19
|
+
} as never,
|
|
20
|
+
{ title: "Select item", items, done: (result) => results.push(result) },
|
|
21
|
+
);
|
|
22
|
+
return { component, results };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("SelectorComponent", () => {
|
|
26
|
+
it("keeps the current selection visible in a bounded list and confirms it", () => {
|
|
27
|
+
const items = Array.from({ length: 12 }, (_, index) => ({
|
|
28
|
+
value: `value-${index}`,
|
|
29
|
+
label: `Item ${index}`,
|
|
30
|
+
current: index === 10,
|
|
31
|
+
}));
|
|
32
|
+
const { component, results } = createHarness(items);
|
|
33
|
+
|
|
34
|
+
const rendered = component.render(80).join("\n");
|
|
35
|
+
assert.match(rendered, /Item 10/);
|
|
36
|
+
assert.match(rendered, /\(11\/12\)/);
|
|
37
|
+
|
|
38
|
+
component.handleInput("tui.select.confirm");
|
|
39
|
+
assert.deepEqual(results, [{ confirmed: true, value: "value-10" }]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("filters searchable values and reports cancellation", () => {
|
|
43
|
+
const { component, results } = createHarness([
|
|
44
|
+
{ value: "anthropic/opus", label: "opus" },
|
|
45
|
+
{ value: "openai/gpt", label: "gpt" },
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
component.handleInput("gpt");
|
|
49
|
+
component.handleInput("tui.select.confirm");
|
|
50
|
+
component.handleInput("tui.select.cancel");
|
|
51
|
+
|
|
52
|
+
assert.deepEqual(results, [
|
|
53
|
+
{ confirmed: true, value: "openai/gpt" },
|
|
54
|
+
{ confirmed: false },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { describe, it } from "node:test";
|
|
8
|
+
import { acquireSessionLease, canonicalSessionFilePath, SessionLeaseConflictError, sessionLeaseDir } from "../../src/runs/shared/session-lease.ts";
|
|
9
|
+
|
|
10
|
+
function fixture(prefix: string): { root: string; leases: string; sessionFile: string } {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
12
|
+
const sessionFile = path.join(root, "session.jsonl");
|
|
13
|
+
fs.writeFileSync(sessionFile, "", "utf-8");
|
|
14
|
+
return { root, leases: path.join(root, "leases"), sessionFile };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function request(sessionFile: string, runId: string) {
|
|
18
|
+
return { sessionFile, runId, sourceRunId: "source-run", parentSessionId: "parent-session" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function waitForLine(child: ChildProcess, timeoutMs = 10_000): Promise<string> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let stdout = "";
|
|
24
|
+
const timer = setTimeout(() => reject(new Error(`Timed out waiting for child output. stderr: ${String(child.stderr?.read() ?? "")}`)), timeoutMs);
|
|
25
|
+
child.stdout?.setEncoding("utf-8");
|
|
26
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
27
|
+
stdout += chunk;
|
|
28
|
+
const newline = stdout.indexOf("\n");
|
|
29
|
+
if (newline === -1) return;
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
resolve(stdout.slice(0, newline));
|
|
32
|
+
});
|
|
33
|
+
child.once("error", (error) => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
reject(error);
|
|
36
|
+
});
|
|
37
|
+
child.once("exit", (code) => {
|
|
38
|
+
if (stdout.includes("\n")) return;
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
reject(new Error(`Lease child exited with ${code} before reporting acquisition.`));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function waitForExit(child: ChildProcess, timeoutMs = 10_000): Promise<void> {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
if (child.exitCode !== null) {
|
|
48
|
+
resolve();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const timer = setTimeout(() => reject(new Error("Timed out waiting for lease child exit.")), timeoutMs);
|
|
52
|
+
child.once("exit", () => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
resolve();
|
|
55
|
+
});
|
|
56
|
+
child.once("error", (error) => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
reject(error);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pidIsAlive(pid: number): boolean {
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, 0);
|
|
66
|
+
return true;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function waitForPidExit(pid: number, timeoutMs = 10_000): Promise<void> {
|
|
73
|
+
const deadline = Date.now() + timeoutMs;
|
|
74
|
+
while (pidIsAlive(pid)) {
|
|
75
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for pid ${pid} to exit.`);
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("session revival leases", () => {
|
|
81
|
+
it("allows one owner and reports its run metadata on contention", () => {
|
|
82
|
+
const { root, leases, sessionFile } = fixture("pi-session-lease-owner-");
|
|
83
|
+
try {
|
|
84
|
+
const first = acquireSessionLease(request(sessionFile, "revive-a"), {
|
|
85
|
+
rootDir: leases,
|
|
86
|
+
pid: 101,
|
|
87
|
+
hostname: "host-a",
|
|
88
|
+
processStartIdentity: "start-a",
|
|
89
|
+
isProcessAlive: () => true,
|
|
90
|
+
getProcessStartIdentity: () => "start-a",
|
|
91
|
+
token: () => "token-a",
|
|
92
|
+
now: () => 1000,
|
|
93
|
+
});
|
|
94
|
+
assert.throws(
|
|
95
|
+
() => acquireSessionLease(request(sessionFile, "revive-b"), {
|
|
96
|
+
rootDir: leases,
|
|
97
|
+
pid: 202,
|
|
98
|
+
hostname: "host-a",
|
|
99
|
+
processStartIdentity: "start-b",
|
|
100
|
+
isProcessAlive: () => true,
|
|
101
|
+
getProcessStartIdentity: () => "start-a",
|
|
102
|
+
token: () => "token-b",
|
|
103
|
+
}),
|
|
104
|
+
(error: unknown) => error instanceof SessionLeaseConflictError
|
|
105
|
+
&& error.owner?.runId === "revive-a"
|
|
106
|
+
&& /already owned by run 'revive-a'/.test(error.message),
|
|
107
|
+
);
|
|
108
|
+
assert.equal(first.release(), true);
|
|
109
|
+
const second = acquireSessionLease(request(sessionFile, "revive-b"), { rootDir: leases, token: () => "token-b" });
|
|
110
|
+
assert.equal(second.release(), true);
|
|
111
|
+
} finally {
|
|
112
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("keys aliases by the canonical session path", { skip: process.platform === "win32" ? "symlink creation is not portable on Windows CI" : undefined }, () => {
|
|
117
|
+
const { root, leases, sessionFile } = fixture("pi-session-lease-canonical-");
|
|
118
|
+
try {
|
|
119
|
+
const alias = path.join(root, "alias.jsonl");
|
|
120
|
+
fs.symlinkSync(sessionFile, alias);
|
|
121
|
+
assert.equal(canonicalSessionFilePath(alias), canonicalSessionFilePath(sessionFile));
|
|
122
|
+
assert.equal(sessionLeaseDir(alias, leases), sessionLeaseDir(sessionFile, leases));
|
|
123
|
+
const lease = acquireSessionLease(request(alias, "alias-owner"), { rootDir: leases });
|
|
124
|
+
assert.throws(() => acquireSessionLease(request(sessionFile, "direct-owner"), { rootDir: leases }), /already owned by run 'alias-owner'/);
|
|
125
|
+
lease.release();
|
|
126
|
+
} finally {
|
|
127
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("allows different canonical session files concurrently", () => {
|
|
132
|
+
const { root, leases, sessionFile } = fixture("pi-session-lease-distinct-");
|
|
133
|
+
try {
|
|
134
|
+
const otherSession = path.join(root, "other.jsonl");
|
|
135
|
+
fs.writeFileSync(otherSession, "", "utf-8");
|
|
136
|
+
const first = acquireSessionLease(request(sessionFile, "revive-a"), { rootDir: leases });
|
|
137
|
+
const second = acquireSessionLease(request(otherSession, "revive-b"), { rootDir: leases });
|
|
138
|
+
first.release();
|
|
139
|
+
second.release();
|
|
140
|
+
} finally {
|
|
141
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("reclaims only demonstrably dead or PID-reused owners", () => {
|
|
146
|
+
const { root, leases, sessionFile } = fixture("pi-session-lease-stale-");
|
|
147
|
+
try {
|
|
148
|
+
const dead = acquireSessionLease(request(sessionFile, "dead-owner"), {
|
|
149
|
+
rootDir: leases,
|
|
150
|
+
pid: 101,
|
|
151
|
+
hostname: "host-a",
|
|
152
|
+
processStartIdentity: "start-dead",
|
|
153
|
+
token: () => "dead-token",
|
|
154
|
+
});
|
|
155
|
+
const recovered = acquireSessionLease(request(sessionFile, "recovered"), {
|
|
156
|
+
rootDir: leases,
|
|
157
|
+
pid: 202,
|
|
158
|
+
hostname: "host-a",
|
|
159
|
+
processStartIdentity: "start-recovered",
|
|
160
|
+
isProcessAlive: (pid) => pid === 101 ? false : true,
|
|
161
|
+
token: () => "recovered-token",
|
|
162
|
+
});
|
|
163
|
+
assert.equal(recovered.owner.runId, "recovered");
|
|
164
|
+
assert.ok(fs.existsSync(`${dead.leaseDir}.stale-dead-token`));
|
|
165
|
+
recovered.release();
|
|
166
|
+
|
|
167
|
+
const reused = acquireSessionLease(request(sessionFile, "reused-owner"), {
|
|
168
|
+
rootDir: leases,
|
|
169
|
+
pid: 303,
|
|
170
|
+
hostname: "host-a",
|
|
171
|
+
processStartIdentity: "old-start",
|
|
172
|
+
token: () => "reused-token",
|
|
173
|
+
});
|
|
174
|
+
const afterReuse = acquireSessionLease(request(sessionFile, "after-reuse"), {
|
|
175
|
+
rootDir: leases,
|
|
176
|
+
pid: 404,
|
|
177
|
+
hostname: "host-a",
|
|
178
|
+
processStartIdentity: "new-owner-start",
|
|
179
|
+
isProcessAlive: () => true,
|
|
180
|
+
getProcessStartIdentity: (pid) => pid === 303 ? "new-start" : "new-owner-start",
|
|
181
|
+
token: () => "after-reuse-token",
|
|
182
|
+
});
|
|
183
|
+
assert.equal(afterReuse.owner.runId, "after-reuse");
|
|
184
|
+
assert.ok(fs.existsSync(`${reused.leaseDir}.stale-reused-token`));
|
|
185
|
+
afterReuse.release();
|
|
186
|
+
} finally {
|
|
187
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("does not reclaim a dead runner while its writer is live or still spawning", () => {
|
|
192
|
+
const { root, leases, sessionFile } = fixture("pi-session-lease-writer-state-");
|
|
193
|
+
try {
|
|
194
|
+
const lease = acquireSessionLease(request(sessionFile, "runner-owner"), {
|
|
195
|
+
rootDir: leases,
|
|
196
|
+
pid: 101,
|
|
197
|
+
hostname: "host-a",
|
|
198
|
+
processStartIdentity: "runner-start",
|
|
199
|
+
getProcessStartIdentity: (pid) => pid === 202 ? "writer-start" : undefined,
|
|
200
|
+
});
|
|
201
|
+
lease.updateWriter({ state: "spawning" });
|
|
202
|
+
assert.throws(
|
|
203
|
+
() => acquireSessionLease(request(sessionFile, "spawning-contender"), {
|
|
204
|
+
rootDir: leases,
|
|
205
|
+
pid: 303,
|
|
206
|
+
hostname: "host-a",
|
|
207
|
+
processStartIdentity: "contender-start",
|
|
208
|
+
isProcessAlive: () => false,
|
|
209
|
+
}),
|
|
210
|
+
/already owned by run 'runner-owner'/,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
lease.updateWriter({ state: "running", pid: 202 });
|
|
214
|
+
assert.throws(
|
|
215
|
+
() => acquireSessionLease(request(sessionFile, "writer-contender"), {
|
|
216
|
+
rootDir: leases,
|
|
217
|
+
pid: 303,
|
|
218
|
+
hostname: "host-a",
|
|
219
|
+
processStartIdentity: "contender-start",
|
|
220
|
+
isProcessAlive: (pid) => pid === 202,
|
|
221
|
+
getProcessStartIdentity: (pid) => pid === 202 ? "writer-start" : undefined,
|
|
222
|
+
}),
|
|
223
|
+
/already owned by run 'runner-owner'/,
|
|
224
|
+
);
|
|
225
|
+
lease.release();
|
|
226
|
+
} finally {
|
|
227
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("does not reclaim unverifiable owners on another host", () => {
|
|
232
|
+
const { root, leases, sessionFile } = fixture("pi-session-lease-host-");
|
|
233
|
+
try {
|
|
234
|
+
const lease = acquireSessionLease(request(sessionFile, "remote-owner"), {
|
|
235
|
+
rootDir: leases,
|
|
236
|
+
pid: 101,
|
|
237
|
+
hostname: "host-a",
|
|
238
|
+
processStartIdentity: "start-a",
|
|
239
|
+
});
|
|
240
|
+
assert.throws(
|
|
241
|
+
() => acquireSessionLease(request(sessionFile, "local-owner"), {
|
|
242
|
+
rootDir: leases,
|
|
243
|
+
pid: 202,
|
|
244
|
+
hostname: "host-b",
|
|
245
|
+
processStartIdentity: "start-b",
|
|
246
|
+
isProcessAlive: () => false,
|
|
247
|
+
}),
|
|
248
|
+
/already owned by run 'remote-owner'/,
|
|
249
|
+
);
|
|
250
|
+
lease.release();
|
|
251
|
+
} finally {
|
|
252
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("keeps an orphaned writer exclusive after its runner exits", async () => {
|
|
257
|
+
const { root, leases, sessionFile } = fixture("pi-session-lease-orphan-writer-");
|
|
258
|
+
const helper = fileURLToPath(new URL("../support/session-lease-child.mjs", import.meta.url));
|
|
259
|
+
const releaseSignal = path.join(root, "unused-release");
|
|
260
|
+
let writerPid: number | undefined;
|
|
261
|
+
try {
|
|
262
|
+
const owner = spawn(process.execPath, ["--experimental-strip-types", helper, sessionFile, leases, releaseSignal, "abandon-with-writer"], {
|
|
263
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
264
|
+
});
|
|
265
|
+
const started = JSON.parse(await waitForLine(owner)) as { acquired?: boolean; writerPid?: number };
|
|
266
|
+
assert.equal(started.acquired, true);
|
|
267
|
+
assert.equal(typeof started.writerPid, "number");
|
|
268
|
+
writerPid = started.writerPid;
|
|
269
|
+
await waitForExit(owner);
|
|
270
|
+
assert.ok(writerPid && pidIsAlive(writerPid));
|
|
271
|
+
assert.throws(() => acquireSessionLease(request(sessionFile, "blocked-by-writer"), { rootDir: leases }), /already owned by run 'child-/);
|
|
272
|
+
|
|
273
|
+
process.kill(writerPid, "SIGTERM");
|
|
274
|
+
await waitForPidExit(writerPid);
|
|
275
|
+
const recovered = acquireSessionLease(request(sessionFile, "after-writer-exit"), { rootDir: leases });
|
|
276
|
+
assert.equal(recovered.owner.runId, "after-writer-exit");
|
|
277
|
+
recovered.release();
|
|
278
|
+
} finally {
|
|
279
|
+
if (writerPid && pidIsAlive(writerPid)) process.kill(writerPid, "SIGKILL");
|
|
280
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("enforces ownership across independent Node processes and elects one stale successor", async () => {
|
|
285
|
+
const { root, leases, sessionFile } = fixture("pi-session-lease-process-");
|
|
286
|
+
const helper = fileURLToPath(new URL("../support/session-lease-child.mjs", import.meta.url));
|
|
287
|
+
const releaseSignal = path.join(root, "release");
|
|
288
|
+
let child: ChildProcess | undefined;
|
|
289
|
+
try {
|
|
290
|
+
child = spawn(process.execPath, ["--experimental-strip-types", helper, sessionFile, leases, releaseSignal, "hold"], {
|
|
291
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
292
|
+
});
|
|
293
|
+
const line = await waitForLine(child);
|
|
294
|
+
const started = JSON.parse(line) as { owner?: { pid?: number; runId?: string } };
|
|
295
|
+
assert.equal(started.owner?.pid, child.pid);
|
|
296
|
+
assert.throws(() => acquireSessionLease(request(sessionFile, "parent-contender"), { rootDir: leases }), /already owned by run 'child-/);
|
|
297
|
+
fs.writeFileSync(releaseSignal, "release", "utf-8");
|
|
298
|
+
await waitForExit(child);
|
|
299
|
+
const afterRelease = acquireSessionLease(request(sessionFile, "parent-after-release"), { rootDir: leases });
|
|
300
|
+
afterRelease.release();
|
|
301
|
+
|
|
302
|
+
fs.rmSync(releaseSignal, { force: true });
|
|
303
|
+
const abandoned = spawn(process.execPath, ["--experimental-strip-types", helper, sessionFile, leases, releaseSignal, "abandon"], {
|
|
304
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
305
|
+
});
|
|
306
|
+
await waitForLine(abandoned);
|
|
307
|
+
await waitForExit(abandoned);
|
|
308
|
+
|
|
309
|
+
const releaseA = path.join(root, "release-a");
|
|
310
|
+
const releaseB = path.join(root, "release-b");
|
|
311
|
+
const contenderA = spawn(process.execPath, ["--experimental-strip-types", helper, sessionFile, leases, releaseA, "hold"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
312
|
+
const contenderB = spawn(process.execPath, ["--experimental-strip-types", helper, sessionFile, leases, releaseB, "hold"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
313
|
+
const [lineA, lineB] = await Promise.all([waitForLine(contenderA), waitForLine(contenderB)]);
|
|
314
|
+
const stateA = JSON.parse(lineA) as { acquired?: boolean };
|
|
315
|
+
const stateB = JSON.parse(lineB) as { acquired?: boolean };
|
|
316
|
+
assert.equal(Number(stateA.acquired === true) + Number(stateB.acquired === true), 1);
|
|
317
|
+
fs.writeFileSync(stateA.acquired ? releaseA : releaseB, "release", "utf-8");
|
|
318
|
+
await Promise.all([waitForExit(contenderA), waitForExit(contenderB)]);
|
|
319
|
+
const recovered = acquireSessionLease(request(sessionFile, "parent-after-contenders"), { rootDir: leases });
|
|
320
|
+
recovered.release();
|
|
321
|
+
} finally {
|
|
322
|
+
if (child && child.exitCode === null) child.kill();
|
|
323
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -3,8 +3,10 @@ import { afterEach, describe, it } from "node:test";
|
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import * as path from "node:path";
|
|
6
|
+
import type { Message, Usage } from "@earendil-works/pi-ai";
|
|
6
7
|
import {
|
|
7
8
|
captureSingleOutputSnapshot,
|
|
9
|
+
extractChildWrittenOutput,
|
|
8
10
|
finalizeSingleOutput,
|
|
9
11
|
formatSavedOutputReference,
|
|
10
12
|
injectOutputPathSystemPrompt,
|
|
@@ -80,12 +82,20 @@ describe("resolveSingleOutputPath", () => {
|
|
|
80
82
|
});
|
|
81
83
|
|
|
82
84
|
describe("injectSingleOutputInstruction", () => {
|
|
83
|
-
it("appends
|
|
84
|
-
const output = injectSingleOutputInstruction("Analyze this", "/tmp/report.md");
|
|
85
|
+
it("appends direct-write instructions for mutation-capable agents", () => {
|
|
86
|
+
const output = injectSingleOutputInstruction("Analyze this", "/tmp/report.md", { tools: ["read", "write"] });
|
|
85
87
|
assert.match(output, /Write your findings to exactly this path: \/tmp\/report.md/);
|
|
86
88
|
assert.match(output, /This path is authoritative for this run\./);
|
|
87
89
|
assert.match(output, /Ignore any other output filename or output path mentioned elsewhere/);
|
|
88
90
|
});
|
|
91
|
+
|
|
92
|
+
it("tells read-only agents to return the artifact for runtime persistence", () => {
|
|
93
|
+
const output = injectSingleOutputInstruction("Analyze this", "/tmp/report.md", { tools: ["read", "grep", "find", "ls"] });
|
|
94
|
+
assert.match(output, /Return the complete artifact in your final response\./);
|
|
95
|
+
assert.match(output, /runtime will persist it to exactly this path: \/tmp\/report\.md/);
|
|
96
|
+
assert.match(output, /Do not call contact_supervisor merely because no write-capable tool is available\./);
|
|
97
|
+
assert.doesNotMatch(output, /Write your findings to exactly this path/);
|
|
98
|
+
});
|
|
89
99
|
});
|
|
90
100
|
|
|
91
101
|
describe("injectOutputPathSystemPrompt", () => {
|
|
@@ -97,6 +107,13 @@ describe("injectOutputPathSystemPrompt", () => {
|
|
|
97
107
|
assert.match(output, /Ignore any other output filename or output path mentioned elsewhere/);
|
|
98
108
|
});
|
|
99
109
|
|
|
110
|
+
it("uses runtime-persistence instructions in read-only system prompts", () => {
|
|
111
|
+
const output = injectOutputPathSystemPrompt("Analyze only", "/tmp/new.md", { tools: ["read"] });
|
|
112
|
+
assert.match(output, /Return the complete artifact in your final response\./);
|
|
113
|
+
assert.match(output, /runtime will persist it to exactly this path: \/tmp\/new\.md/);
|
|
114
|
+
assert.doesNotMatch(output, /Write your findings to exactly this path/);
|
|
115
|
+
});
|
|
116
|
+
|
|
100
117
|
it("leaves prompts unchanged when no output path is active", () => {
|
|
101
118
|
assert.equal(injectOutputPathSystemPrompt("Base prompt", undefined), "Base prompt");
|
|
102
119
|
});
|
|
@@ -146,6 +163,88 @@ describe("resolveSingleOutput", () => {
|
|
|
146
163
|
});
|
|
147
164
|
});
|
|
148
165
|
|
|
166
|
+
describe("extractChildWrittenOutput", () => {
|
|
167
|
+
const usage: Usage = { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
|
|
168
|
+
const toolCall = (id: string, name: string, args: Record<string, unknown>): Message => ({
|
|
169
|
+
role: "assistant",
|
|
170
|
+
content: [{ type: "toolCall", id, name, arguments: args }],
|
|
171
|
+
api: "test",
|
|
172
|
+
provider: "test",
|
|
173
|
+
model: "mock/test-model",
|
|
174
|
+
usage,
|
|
175
|
+
stopReason: "toolUse",
|
|
176
|
+
timestamp: 0,
|
|
177
|
+
});
|
|
178
|
+
const toolResult = (id: string, isError = false): Message => ({
|
|
179
|
+
role: "toolResult",
|
|
180
|
+
toolCallId: id,
|
|
181
|
+
toolName: "write",
|
|
182
|
+
content: [{ type: "text", text: isError ? "write failed" : "ok" }],
|
|
183
|
+
isError,
|
|
184
|
+
timestamp: 0,
|
|
185
|
+
});
|
|
186
|
+
const completedWrite = (id: string, writePath: string, content: string): Message[] => [
|
|
187
|
+
toolCall(id, "write", { path: writePath, content }),
|
|
188
|
+
toolResult(id),
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
it("returns the last successfully written content for the configured path", () => {
|
|
192
|
+
const messages = [
|
|
193
|
+
...completedWrite("w1", "/tmp/out.md", "draft"),
|
|
194
|
+
...completedWrite("w2", "/tmp/other.md", "unrelated"),
|
|
195
|
+
...completedWrite("w3", "/tmp/out.md", "final report"),
|
|
196
|
+
];
|
|
197
|
+
assert.equal(extractChildWrittenOutput(messages, "/tmp/out.md", "/repo"), "final report");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("ignores write calls whose tool result failed", () => {
|
|
201
|
+
const failedOnly = [toolCall("w1", "write", { path: "/tmp/out.md", content: "never landed" }), toolResult("w1", true)];
|
|
202
|
+
assert.equal(extractChildWrittenOutput(failedOnly, "/tmp/out.md", "/repo"), undefined);
|
|
203
|
+
|
|
204
|
+
const failedAfterSuccess = [
|
|
205
|
+
...completedWrite("w1", "/tmp/out.md", "landed"),
|
|
206
|
+
toolCall("w2", "write", { path: "/tmp/out.md", content: "never landed" }),
|
|
207
|
+
toolResult("w2", true),
|
|
208
|
+
];
|
|
209
|
+
assert.equal(extractChildWrittenOutput(failedAfterSuccess, "/tmp/out.md", "/repo"), "landed");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("ignores write calls with no confirmed successful tool result", () => {
|
|
213
|
+
const missingResult = [toolCall("w1", "write", { path: "/tmp/out.md", content: "unconfirmed" })];
|
|
214
|
+
assert.equal(extractChildWrittenOutput(missingResult, "/tmp/out.md", "/repo"), undefined);
|
|
215
|
+
|
|
216
|
+
const missingStatus = [
|
|
217
|
+
toolCall("w1", "write", { path: "/tmp/out.md", content: "unconfirmed" }),
|
|
218
|
+
{ role: "toolResult", toolCallId: "w1", toolName: "write", content: [{ type: "text", text: "unknown" }], timestamp: 0 } as Message,
|
|
219
|
+
];
|
|
220
|
+
assert.equal(extractChildWrittenOutput(missingStatus, "/tmp/out.md", "/repo"), undefined);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("resolves relative write paths against the child cwd", () => {
|
|
224
|
+
const messages = completedWrite("w1", "reports/out.md", "relative content");
|
|
225
|
+
assert.equal(extractChildWrittenOutput(messages, "/repo/reports/out.md", "/repo"), "relative content");
|
|
226
|
+
assert.equal(extractChildWrittenOutput(messages, "/elsewhere/reports/out.md", "/repo"), undefined);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("matches configured output paths case-insensitively on Windows", { skip: process.platform !== "win32" ? "Windows path comparison" : undefined }, () => {
|
|
230
|
+
const writePath = path.join("C:\\Repo", "Reports", "Output.md");
|
|
231
|
+
const configuredPath = writePath.toLowerCase();
|
|
232
|
+
assert.equal(extractChildWrittenOutput(completedWrite("w1", writePath, "Windows report"), configuredPath, "C:\\Repo"), "Windows report");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("ignores non-write tools and missing arguments", () => {
|
|
236
|
+
const messages = [
|
|
237
|
+
toolCall("e1", "edit", { path: "/tmp/out.md", oldText: "a", newText: "b" }),
|
|
238
|
+
toolResult("e1"),
|
|
239
|
+
toolCall("w1", "write", { path: "/tmp/out.md" }),
|
|
240
|
+
toolResult("w1"),
|
|
241
|
+
];
|
|
242
|
+
assert.equal(extractChildWrittenOutput(messages, "/tmp/out.md", "/repo"), undefined);
|
|
243
|
+
assert.equal(extractChildWrittenOutput(undefined, "/tmp/out.md", "/repo"), undefined);
|
|
244
|
+
assert.equal(extractChildWrittenOutput(messages, undefined, "/repo"), undefined);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
149
248
|
describe("formatSavedOutputReference", () => {
|
|
150
249
|
it("includes absolute path, human-readable size, and line count", () => {
|
|
151
250
|
const reportPath = path.join(os.tmpdir(), "report.md");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import * as fs from "node:fs";
|
|
3
4
|
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
@@ -229,6 +230,12 @@ describe("skills filesystem fallback", () => {
|
|
|
229
230
|
const { resolved, missing } = resolveSkills(["pi-subagents", "safe-bash"], tempDir);
|
|
230
231
|
assert.deepEqual(missing, ["pi-subagents"]);
|
|
231
232
|
assert.deepEqual(resolved.map((skill) => skill.name), ["safe-bash"]);
|
|
233
|
+
|
|
234
|
+
const agentDir = path.join(tempDir, "agent");
|
|
235
|
+
writeSkillFile(path.join(agentDir, "skills", "pi-subagents"), "Still parent-only.");
|
|
236
|
+
const local = resolveSkills(["pi-subagents"], tempDir, ["./skills"], agentDir);
|
|
237
|
+
assert.deepEqual(local.resolved, []);
|
|
238
|
+
assert.deepEqual(local.missing, ["pi-subagents"]);
|
|
232
239
|
});
|
|
233
240
|
|
|
234
241
|
it("classifies package-provided skills as project-package", () => {
|
|
@@ -297,6 +304,59 @@ describe("skills filesystem fallback", () => {
|
|
|
297
304
|
assert.equal(resolved[0]?.source, "project-package");
|
|
298
305
|
});
|
|
299
306
|
|
|
307
|
+
it("skips optional global npm discovery in offline mode", () => {
|
|
308
|
+
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
309
|
+
const binDir = path.join(tempDir, "bin");
|
|
310
|
+
const fakeHome = path.join(tempDir, "home");
|
|
311
|
+
const marker = path.join(tempDir, "npm-calls.txt");
|
|
312
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
313
|
+
fs.mkdirSync(fakeHome, { recursive: true });
|
|
314
|
+
fs.writeFileSync(
|
|
315
|
+
path.join(binDir, "npm"),
|
|
316
|
+
"#!/bin/sh\nprintf 'npm-root-called\\n' >> \"$PI_DISCOVERY_MARKER\"\nexit 1\n",
|
|
317
|
+
{ encoding: "utf-8", mode: 0o755 },
|
|
318
|
+
);
|
|
319
|
+
fs.writeFileSync(
|
|
320
|
+
path.join(binDir, "npm.cmd"),
|
|
321
|
+
"@echo off\r\n>>\"%PI_DISCOVERY_MARKER%\" echo npm-root-called\r\nexit /b 1\r\n",
|
|
322
|
+
"utf-8",
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const script = `
|
|
326
|
+
import fs from "node:fs";
|
|
327
|
+
const [{ clearSkillCache, discoverAvailableSkills }, { discoverAgents }] = await Promise.all([
|
|
328
|
+
import("./src/agents/skills.ts"),
|
|
329
|
+
import("./src/agents/agents.ts"),
|
|
330
|
+
]);
|
|
331
|
+
discoverAvailableSkills(process.cwd());
|
|
332
|
+
discoverAgents(process.cwd(), "both");
|
|
333
|
+
if (fs.existsSync(process.env.PI_DISCOVERY_MARKER)) {
|
|
334
|
+
throw new Error("npm was invoked while PI_OFFLINE was enabled");
|
|
335
|
+
}
|
|
336
|
+
delete process.env.PI_OFFLINE;
|
|
337
|
+
clearSkillCache();
|
|
338
|
+
discoverAvailableSkills(process.cwd());
|
|
339
|
+
discoverAgents(process.cwd(), "both");
|
|
340
|
+
`;
|
|
341
|
+
execFileSync(
|
|
342
|
+
process.execPath,
|
|
343
|
+
["--experimental-transform-types", "--import", "./test/support/register-loader.mjs", "--input-type=module", "--eval", script],
|
|
344
|
+
{
|
|
345
|
+
cwd: projectRoot,
|
|
346
|
+
env: {
|
|
347
|
+
...process.env,
|
|
348
|
+
HOME: fakeHome,
|
|
349
|
+
USERPROFILE: fakeHome,
|
|
350
|
+
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
351
|
+
PI_DISCOVERY_MARKER: marker,
|
|
352
|
+
PI_OFFLINE: "1",
|
|
353
|
+
},
|
|
354
|
+
stdio: "pipe",
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
assert.deepEqual(fs.readFileSync(marker, "utf-8").trim().split(/\r?\n/), ["npm-root-called", "npm-root-called"]);
|
|
358
|
+
});
|
|
359
|
+
|
|
300
360
|
it("falls back to the runtime cwd when the execution cwd lacks the skill", () => {
|
|
301
361
|
const nestedDir = path.join(tempDir, "nested");
|
|
302
362
|
fs.mkdirSync(nestedDir, { recursive: true });
|
|
@@ -409,13 +469,63 @@ describe("skills filesystem fallback", () => {
|
|
|
409
469
|
}
|
|
410
470
|
});
|
|
411
471
|
|
|
472
|
+
it("resolves agent-local files and directories before global skills without publishing them", () => {
|
|
473
|
+
makeProjectSkill(tempDir, "shared", "global body");
|
|
474
|
+
const agentDir = path.join(tempDir, "agents", "nested");
|
|
475
|
+
writeSkillFile(path.join(agentDir, "skills", "shared"), "local shared body");
|
|
476
|
+
writeSkillFile(path.join(agentDir, "direct"), "local direct body");
|
|
477
|
+
|
|
478
|
+
const local = resolveSkills(["shared", "direct", "missing"], tempDir, ["./skills", "./direct/SKILL.md"], agentDir);
|
|
479
|
+
assert.deepEqual(local.resolved.map((skill) => [skill.name, skill.content]), [
|
|
480
|
+
["shared", "local shared body"],
|
|
481
|
+
["direct", "local direct body"],
|
|
482
|
+
]);
|
|
483
|
+
assert.deepEqual(local.missing, ["missing"]);
|
|
484
|
+
assert.equal(resolveSkills(["shared"], tempDir).resolved[0]?.content, "global body");
|
|
485
|
+
assert.equal(discoverAvailableSkills(tempDir).some((skill) => skill.name === "direct"), false);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("does not read malformed global settings when every selected local skill resolves", () => {
|
|
489
|
+
const agentDir = path.join(tempDir, "agents", "nested");
|
|
490
|
+
writeSkillFile(path.join(agentDir, "skills", "local"), "local body");
|
|
491
|
+
fs.mkdirSync(path.join(tempDir, ".selesai"), { recursive: true });
|
|
492
|
+
fs.writeFileSync(path.join(tempDir, ".selesai", "settings.json"), "{bad-json", "utf-8");
|
|
493
|
+
|
|
494
|
+
const result = resolveSkills(["local"], tempDir, ["./skills"], agentDir);
|
|
495
|
+
assert.deepEqual(result.missing, []);
|
|
496
|
+
assert.equal(result.resolved[0]?.content, "local body");
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("falls back globally when an agent-local skill candidate cannot be read", () => {
|
|
500
|
+
makeProjectSkill(tempDir, "shared", "global body");
|
|
501
|
+
const agentDir = path.join(tempDir, "agents", "nested");
|
|
502
|
+
const invalidLocalFile = path.join(agentDir, "skills", "shared", "SKILL.md");
|
|
503
|
+
fs.mkdirSync(invalidLocalFile, { recursive: true });
|
|
504
|
+
|
|
505
|
+
const result = resolveSkills(["shared"], tempDir, ["./skills"], agentDir);
|
|
506
|
+
assert.deepEqual(result.missing, []);
|
|
507
|
+
assert.equal(result.resolved[0]?.content, "global body");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("keeps same-named agent-local skills isolated between invocations", () => {
|
|
511
|
+
makeProjectSkill(tempDir, "global-only", "global fallback");
|
|
512
|
+
const one = path.join(tempDir, "one");
|
|
513
|
+
const two = path.join(tempDir, "two");
|
|
514
|
+
writeSkillFile(path.join(one, "skills", "private"), "one private");
|
|
515
|
+
writeSkillFile(path.join(two, "skills", "private"), "two private");
|
|
516
|
+
|
|
517
|
+
assert.equal(resolveSkills(["private", "global-only"], tempDir, ["./skills"], one).resolved[0]?.content, "one private");
|
|
518
|
+
assert.equal(resolveSkills(["private", "global-only"], tempDir, ["./skills"], two).resolved[0]?.content, "two private");
|
|
519
|
+
assert.equal(resolveSkills(["global-only"], tempDir, ["./skills"], one).resolved[0]?.content, "global fallback");
|
|
520
|
+
});
|
|
521
|
+
|
|
412
522
|
it("surfaces malformed project settings files instead of silently ignoring them", () => {
|
|
413
523
|
fs.mkdirSync(path.join(tempDir, ".selesai"), { recursive: true });
|
|
414
524
|
fs.writeFileSync(path.join(tempDir, ".selesai", "settings.json"), "{bad-json", "utf-8");
|
|
415
525
|
|
|
416
526
|
assert.throws(
|
|
417
527
|
() => resolveSkills(["missing-skill"], tempDir),
|
|
418
|
-
/Failed to read skills settings file .+\.
|
|
528
|
+
/Failed to read skills settings file .+\.selesai[\\/]settings\.json/,
|
|
419
529
|
);
|
|
420
530
|
});
|
|
421
531
|
|