@hassangameryt/freeflow 0.2.0 → 0.4.0
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/.agents/plugins/marketplace.json +1 -1
- package/.claude-plugin/marketplace.json +4 -4
- package/{plugins/freeflow/.claude-plugin → .claude-plugin}/plugin.json +5 -2
- package/{plugins/freeflow/.codex-plugin → .codex-plugin}/plugin.json +8 -9
- package/README.md +194 -127
- package/assets/feedback-loop.png +0 -0
- package/assets/freeflow-plugin-icon.png +0 -0
- package/assets/freeflow_workflow_map.png +0 -0
- package/assets/workflow.png +0 -0
- package/command-surface.json +129 -0
- package/hooks/freeflow-runtime-context.mjs +419 -0
- package/hooks/tests/check-runtime-context-hook.sh +223 -0
- package/package.json +41 -22
- package/pi-extension/dist/index.js +269 -0
- package/pi-extension/dist/integrations/mcp-config.js +29 -0
- package/pi-extension/dist/output-router/host-producer-identification.js +232 -0
- package/pi-extension/dist/output-router/native-safety-net.js +216 -0
- package/pi-extension/dist/output-router/observed-tool-routing.js +122 -0
- package/pi-extension/dist/output-router/renderers.js +1023 -0
- package/pi-extension/dist/output-router/router-tools.js +554 -0
- package/pi-extension/dist/output-router/schemas.js +383 -0
- package/pi-extension/dist/output-router/status.js +744 -0
- package/pi-extension/dist/output-router/utils.js +502 -0
- package/pi-extension/dist/runtime/runtime-context.js +798 -0
- package/pi-extension/dist/settings/settings-tui.js +314 -0
- package/pi-extension/dist/settings/settings-ui.js +1487 -0
- package/pi-extension/freeflow/index.js +1 -0
- package/pi-extension/src/index.ts +295 -0
- package/pi-extension/src/integrations/mcp-config.ts +32 -0
- package/pi-extension/src/output-router/host-producer-identification.ts +256 -0
- package/pi-extension/src/output-router/native-safety-net.ts +248 -0
- package/pi-extension/src/output-router/observed-tool-routing.ts +134 -0
- package/pi-extension/src/output-router/renderers.ts +1108 -0
- package/pi-extension/src/output-router/router-tools.ts +579 -0
- package/pi-extension/src/output-router/schemas.ts +387 -0
- package/pi-extension/src/output-router/status.ts +791 -0
- package/pi-extension/src/output-router/utils.ts +554 -0
- package/pi-extension/src/runtime/runtime-context.ts +895 -0
- package/pi-extension/src/settings/settings-tui.ts +408 -0
- package/pi-extension/src/settings/settings-ui.ts +1730 -0
- package/pi-extension/tests/integration/pi-extension.test.js +4018 -0
- package/pi-extension/tests/output-router/observed-producers.test.js +132 -0
- package/pi-extension/tests/output-router/search.test.js +722 -0
- package/pi-extension/tests/settings/settings-tui.test.js +243 -0
- package/pi-extension/tsconfig.json +16 -0
- package/{plugins/freeflow/router/dist → router/dist/benchmarks}/benchmark-harness.d.ts +16 -13
- package/router/dist/benchmarks/benchmark-harness.js +92 -0
- package/router/dist/benchmarks/benchmarks.d.ts +109 -0
- package/router/dist/benchmarks/benchmarks.js +979 -0
- package/router/dist/benchmarks/codex-qa-benchmarks.d.ts +95 -0
- package/router/dist/benchmarks/codex-qa-benchmarks.js +500 -0
- package/router/dist/benchmarks/command-benchmarks.d.ts +143 -0
- package/router/dist/benchmarks/command-benchmarks.js +698 -0
- package/router/dist/benchmarks/context-mode-normalized-benchmarks.d.ts +82 -0
- package/router/dist/benchmarks/context-mode-normalized-benchmarks.js +773 -0
- package/router/dist/benchmarks/context-mode-real-deep-benchmark.d.ts +132 -0
- package/router/dist/benchmarks/context-mode-real-deep-benchmark.js +1380 -0
- package/router/dist/benchmarks/context-saving-baseline.d.ts +60 -0
- package/router/dist/benchmarks/context-saving-baseline.js +470 -0
- package/router/dist/benchmarks/index-benchmarks.d.ts +103 -0
- package/router/dist/benchmarks/index-benchmarks.js +862 -0
- package/router/dist/benchmarks/storage-policy-benchmarks.d.ts +101 -0
- package/router/dist/benchmarks/storage-policy-benchmarks.js +618 -0
- package/router/dist/benchmarks/vault-index-benchmarks.d.ts +56 -0
- package/router/dist/benchmarks/vault-index-benchmarks.js +322 -0
- package/router/dist/config/config.d.ts +103 -0
- package/router/dist/config/config.js +632 -0
- package/{plugins/freeflow/router/dist → router/dist/config}/router-contract.d.ts +2 -2
- package/router/dist/config/router-contract.js +54 -0
- package/{plugins/freeflow/router/dist → router/dist/config}/schema.d.ts +19 -10
- package/router/dist/config/schema.js +648 -0
- package/router/dist/config/types.d.ts +573 -0
- package/router/dist/config/types.js +41 -0
- package/router/dist/evidence/bounded-evidence.d.ts +40 -0
- package/router/dist/evidence/bounded-evidence.js +323 -0
- package/router/dist/evidence/evidence-range-selector.d.ts +24 -0
- package/router/dist/evidence/evidence-range-selector.js +232 -0
- package/router/dist/evidence/evidence-search.d.ts +27 -0
- package/router/dist/evidence/evidence-search.js +490 -0
- package/{plugins/freeflow/router/dist → router/dist/evidence}/evidence.d.ts +18 -18
- package/router/dist/evidence/evidence.js +200 -0
- package/router/dist/evidence/failure-contracts.d.ts +37 -0
- package/router/dist/evidence/failure-contracts.js +166 -0
- package/router/dist/evidence/line-ranges.d.ts +24 -0
- package/router/dist/evidence/line-ranges.js +22 -0
- package/router/dist/experiments/local-index.d.ts +64 -0
- package/router/dist/experiments/local-index.js +581 -0
- package/router/dist/index.d.ts +30 -0
- package/router/dist/index.js +32 -0
- package/router/dist/local/local-traversal.d.ts +18 -0
- package/router/dist/local/local-traversal.js +334 -0
- package/router/dist/processing/engine.d.ts +132 -0
- package/router/dist/processing/engine.js +533 -0
- package/router/dist/processing/reducers.d.ts +280 -0
- package/router/dist/processing/reducers.js +1673 -0
- package/router/dist/processing/renderers.d.ts +31 -0
- package/router/dist/processing/renderers.js +195 -0
- package/router/dist/processing/scripts.d.ts +91 -0
- package/router/dist/processing/scripts.js +386 -0
- package/{plugins/freeflow/router/dist → router/dist/repo}/repo-traversal.d.ts +9 -9
- package/router/dist/repo/repo-traversal.js +245 -0
- package/router/dist/routing/observed-reducers.d.ts +41 -0
- package/router/dist/routing/observed-reducers.js +661 -0
- package/router/dist/routing/observed-routing.d.ts +26 -0
- package/router/dist/routing/observed-routing.js +378 -0
- package/router/dist/routing/parsers.d.ts +16 -0
- package/router/dist/routing/parsers.js +521 -0
- package/router/dist/routing/run-filters.d.ts +60 -0
- package/router/dist/routing/run-filters.js +238 -0
- package/router/dist/routing/run-reducers.d.ts +49 -0
- package/router/dist/routing/run-reducers.js +122 -0
- package/router/dist/sandbox/adapter-roots.d.ts +46 -0
- package/router/dist/sandbox/adapter-roots.js +66 -0
- package/router/dist/sandbox/eryx-python-adapter.d.ts +13 -0
- package/router/dist/sandbox/eryx-python-adapter.js +868 -0
- package/router/dist/sandbox/jq-wasm-adapter.d.ts +9 -0
- package/router/dist/sandbox/jq-wasm-adapter.js +429 -0
- package/router/dist/sandbox/quickjs-wasi-adapter.d.ts +13 -0
- package/router/dist/sandbox/quickjs-wasi-adapter.js +384 -0
- package/router/dist/sandbox/script-sandbox.d.ts +134 -0
- package/router/dist/sandbox/script-sandbox.js +337 -0
- package/router/dist/setup/script-transform-adapters.d.ts +32 -0
- package/router/dist/setup/script-transform-adapters.js +278 -0
- package/router/dist/tools/batch.d.ts +33 -0
- package/router/dist/tools/batch.js +586 -0
- package/router/dist/tools/run.d.ts +62 -0
- package/router/dist/tools/run.js +1304 -0
- package/router/dist/tools/search.d.ts +66 -0
- package/router/dist/tools/search.js +1364 -0
- package/router/dist/transform/engine.d.ts +168 -0
- package/router/dist/transform/engine.js +2072 -0
- package/router/dist/vault/vault-index.d.ts +125 -0
- package/router/dist/vault/vault-index.js +416 -0
- package/router/dist/vault/vault.d.ts +138 -0
- package/router/dist/vault/vault.js +630 -0
- package/runtime/interaction-contract.md +16 -0
- package/skills/bypass/SKILL.md +82 -0
- package/skills/commit-work/SKILL.md +106 -0
- package/skills/commit-work/references/staging-decisions.md +73 -0
- package/skills/decision-gate/SKILL.md +48 -0
- package/skills/design-for-depth/SKILL.md +106 -0
- package/skills/design-for-depth/references/design-pressure-signals.md +314 -0
- package/skills/design-for-depth/references/interface-design-loop.md +156 -0
- package/skills/design-for-depth/references/software-design-philosophy.md +248 -0
- package/skills/diagnose-failure/SKILL.md +105 -0
- package/skills/diagnose-failure/references/diagnostic-loop-catalog.md +36 -0
- package/skills/diagnose-failure/references/flaky-and-performance.md +43 -0
- package/skills/discuss/SKILL.md +121 -0
- package/skills/discuss/references/checkpoints.md +66 -0
- package/skills/evaluate-skill/SKILL.md +105 -0
- package/skills/evaluate-skill/references/definition-schema.md +345 -0
- package/skills/evaluate-skill/references/evaluation-design.md +86 -0
- package/skills/evaluate-skill/references/execution-and-evidence.md +85 -0
- package/skills/evaluate-skill/references/review-and-revision.md +51 -0
- package/skills/evaluate-skill/scripts/lib/definitions.mjs +296 -0
- package/skills/evaluate-skill/scripts/lib/evidence.mjs +46 -0
- package/skills/evaluate-skill/scripts/lib/grade-comparison.mjs +60 -0
- package/skills/evaluate-skill/scripts/lib/grade-interaction.mjs +148 -0
- package/skills/evaluate-skill/scripts/lib/grade-support.mjs +126 -0
- package/skills/evaluate-skill/scripts/lib/grade-workspace.mjs +192 -0
- package/skills/evaluate-skill/scripts/lib/grade.mjs +56 -0
- package/skills/evaluate-skill/scripts/lib/pi.mjs +489 -0
- package/skills/evaluate-skill/scripts/lib/process.mjs +79 -0
- package/skills/evaluate-skill/scripts/lib/rpc.mjs +444 -0
- package/skills/evaluate-skill/scripts/lib/runner.mjs +346 -0
- package/skills/evaluate-skill/scripts/lib/sandbox.mjs +409 -0
- package/skills/evaluate-skill/scripts/lib/view.mjs +337 -0
- package/skills/evaluate-skill/scripts/pi-guard.mjs +164 -0
- package/skills/evaluate-skill/scripts/skill-eval.mjs +101 -0
- package/skills/execute-work/SKILL.md +99 -0
- package/skills/execute-work/references/code-practices.md +78 -0
- package/skills/execute-work/references/execution-loop.md +77 -0
- package/skills/finish-branch/SKILL.md +117 -0
- package/skills/finish-branch/references/integration-options.md +80 -0
- package/skills/handoff/SKILL.md +92 -0
- package/skills/handoff/references/templates.md +141 -0
- package/skills/launch-work/SKILL.md +116 -0
- package/skills/launch-work/references/launch-readiness.md +85 -0
- package/skills/migration-work/SKILL.md +115 -0
- package/skills/migration-work/references/migration-lifecycle.md +82 -0
- package/skills/mode-contract/SKILL.md +82 -0
- package/skills/output-router/SKILL.md +206 -0
- package/skills/output-router/references/safety-policy.md +40 -0
- package/skills/release-work/SKILL.md +118 -0
- package/skills/release-work/references/release-evidence.md +78 -0
- package/skills/review-artifact/SKILL.md +126 -0
- package/skills/review-artifact/references/reviewer-prompt.md +148 -0
- package/skills/review-work/SKILL.md +126 -0
- package/skills/review-work/references/reviewer-prompt.md +134 -0
- package/skills/review-work/references/security-risk-lens.md +67 -0
- package/skills/setup-freeflow/SKILL.md +98 -0
- package/skills/setup-freeflow/references/activation-contract.md +120 -0
- package/skills/setup-freeflow/references/host-setup.md +72 -0
- package/skills/setup-freeflow/references/output-router-setup.md +110 -0
- package/skills/simplify-code/SKILL.md +80 -0
- package/skills/simplify-code/references/simplification-patterns.md +70 -0
- package/skills/tdd/SKILL.md +96 -0
- package/skills/tdd/references/test-design.md +96 -0
- package/skills/track-work/SKILL.md +84 -0
- package/skills/track-work/references/working-record-schema.md +378 -0
- package/skills/verify-work/SKILL.md +85 -0
- package/skills/verify-work/references/browser-runtime-evidence.md +55 -0
- package/skills/verify-work/references/integration-evidence.md +51 -0
- package/skills/verify-work/references/performance-evidence.md +57 -0
- package/skills/workflow/SKILL.md +91 -0
- package/skills/workflow/references/domain-skill-composition.md +57 -0
- package/skills/workflow/references/workflow-loop.md +92 -0
- package/skills/write-plan/SKILL.md +123 -0
- package/skills/write-plan/references/plan-shapes.md +156 -0
- package/skills/write-skill/SKILL.md +119 -0
- package/skills/write-skill/references/activation-boundaries.md +62 -0
- package/skills/write-skill/references/agent-first-instructions.md +87 -0
- package/skills/write-skill/references/progressive-disclosure.md +63 -0
- package/skills/write-skill/scripts/lib/skill-author.mjs +653 -0
- package/skills/write-skill/scripts/skill-author.mjs +104 -0
- package/skills/write-spec/SKILL.md +104 -0
- package/skills/write-spec/references/artifact-standards.md +86 -0
- package/skills/write-spec/references/decision-records.md +77 -0
- package/skills/write-spec/references/spec-shapes.md +128 -0
- package/plugins/freeflow/command-surface.json +0 -77
- package/plugins/freeflow/docs/README.md +0 -40
- package/plugins/freeflow/docs/adr/0001-three-modes.md +0 -27
- package/plugins/freeflow/docs/adr/0002-evals-before-hooks.md +0 -17
- package/plugins/freeflow/docs/adr/0003-release-boundary.md +0 -21
- package/plugins/freeflow/docs/adr/0004-research-replaces-shallow-discovery-skills.md +0 -26
- package/plugins/freeflow/docs/adr/README.md +0 -8
- package/plugins/freeflow/docs/architecture.md +0 -86
- package/plugins/freeflow/docs/output-router.md +0 -212
- package/plugins/freeflow/docs/release-evidence.md +0 -69
- package/plugins/freeflow/docs/skills.md +0 -43
- package/plugins/freeflow/docs/workflow.md +0 -57
- package/plugins/freeflow/hooks/freeflow-runtime-context.mjs +0 -257
- package/plugins/freeflow/pi-extension/index.js +0 -1176
- package/plugins/freeflow/router/dist/benchmark-harness.js +0 -92
- package/plugins/freeflow/router/dist/benchmarks.d.ts +0 -102
- package/plugins/freeflow/router/dist/benchmarks.js +0 -880
- package/plugins/freeflow/router/dist/bounded-evidence.d.ts +0 -40
- package/plugins/freeflow/router/dist/bounded-evidence.js +0 -310
- package/plugins/freeflow/router/dist/codex-qa-benchmarks.d.ts +0 -91
- package/plugins/freeflow/router/dist/codex-qa-benchmarks.js +0 -461
- package/plugins/freeflow/router/dist/command-benchmarks.d.ts +0 -128
- package/plugins/freeflow/router/dist/command-benchmarks.js +0 -652
- package/plugins/freeflow/router/dist/config.d.ts +0 -22
- package/plugins/freeflow/router/dist/config.js +0 -114
- package/plugins/freeflow/router/dist/evidence-range-selector.d.ts +0 -22
- package/plugins/freeflow/router/dist/evidence-range-selector.js +0 -180
- package/plugins/freeflow/router/dist/evidence-search.d.ts +0 -25
- package/plugins/freeflow/router/dist/evidence-search.js +0 -450
- package/plugins/freeflow/router/dist/evidence.js +0 -196
- package/plugins/freeflow/router/dist/experimental-local-index.d.ts +0 -1
- package/plugins/freeflow/router/dist/experimental-local-index.js +0 -1
- package/plugins/freeflow/router/dist/experiments/local-index.d.ts +0 -58
- package/plugins/freeflow/router/dist/experiments/local-index.js +0 -562
- package/plugins/freeflow/router/dist/index-benchmarks.d.ts +0 -84
- package/plugins/freeflow/router/dist/index-benchmarks.js +0 -458
- package/plugins/freeflow/router/dist/index.d.ts +0 -12
- package/plugins/freeflow/router/dist/index.js +0 -12
- package/plugins/freeflow/router/dist/line-ranges.d.ts +0 -22
- package/plugins/freeflow/router/dist/line-ranges.js +0 -22
- package/plugins/freeflow/router/dist/parsers.d.ts +0 -16
- package/plugins/freeflow/router/dist/parsers.js +0 -473
- package/plugins/freeflow/router/dist/repo-traversal.js +0 -242
- package/plugins/freeflow/router/dist/retrieve.d.ts +0 -33
- package/plugins/freeflow/router/dist/retrieve.js +0 -855
- package/plugins/freeflow/router/dist/router-contract.js +0 -54
- package/plugins/freeflow/router/dist/run.d.ts +0 -26
- package/plugins/freeflow/router/dist/run.js +0 -356
- package/plugins/freeflow/router/dist/schema.js +0 -354
- package/plugins/freeflow/router/dist/types.d.ts +0 -234
- package/plugins/freeflow/router/dist/types.js +0 -9
- package/plugins/freeflow/router/dist/vault.d.ts +0 -80
- package/plugins/freeflow/router/dist/vault.js +0 -484
- package/plugins/freeflow/skills/bypass/SKILL.md +0 -68
- package/plugins/freeflow/skills/commit-work/SKILL.md +0 -98
- package/plugins/freeflow/skills/commit-work/references/staging-decisions.md +0 -51
- package/plugins/freeflow/skills/diagnose-failure/SKILL.md +0 -95
- package/plugins/freeflow/skills/diagnose-failure/references/feedback-loop-catalog.md +0 -25
- package/plugins/freeflow/skills/diagnose-failure/references/flaky-and-performance.md +0 -31
- package/plugins/freeflow/skills/evaluate-skill/SKILL.md +0 -96
- package/plugins/freeflow/skills/evaluate-skill/references/eval-patterns.md +0 -41
- package/plugins/freeflow/skills/evaluate-skill/references/grading-priority.md +0 -33
- package/plugins/freeflow/skills/execute-plan/SKILL.md +0 -132
- package/plugins/freeflow/skills/execute-plan/references/execution-map.md +0 -122
- package/plugins/freeflow/skills/handoff/SKILL.md +0 -66
- package/plugins/freeflow/skills/handoff/references/templates.md +0 -64
- package/plugins/freeflow/skills/interview-gate/SKILL.md +0 -90
- package/plugins/freeflow/skills/mode-contract/SKILL.md +0 -79
- package/plugins/freeflow/skills/output-router/SKILL.md +0 -69
- package/plugins/freeflow/skills/output-router/references/safety-policy.md +0 -33
- package/plugins/freeflow/skills/research/ARTIFACT-DESTINATIONS.md +0 -58
- package/plugins/freeflow/skills/research/CHECKPOINTS.md +0 -74
- package/plugins/freeflow/skills/research/LANGUAGE.md +0 -61
- package/plugins/freeflow/skills/research/SKILL.md +0 -128
- package/plugins/freeflow/skills/review-artifact/SKILL.md +0 -115
- package/plugins/freeflow/skills/review-artifact/references/reviewer-prompt.md +0 -56
- package/plugins/freeflow/skills/review-work/SKILL.md +0 -96
- package/plugins/freeflow/skills/review-work/references/reviewer-prompt.md +0 -97
- package/plugins/freeflow/skills/setup-freeflow/SKILL.md +0 -94
- package/plugins/freeflow/skills/setup-freeflow/references/activation-contract.md +0 -99
- package/plugins/freeflow/skills/setup-freeflow/references/host-setup.md +0 -35
- package/plugins/freeflow/skills/setup-freeflow/references/output-router-setup.md +0 -52
- package/plugins/freeflow/skills/verify-work/SKILL.md +0 -62
- package/plugins/freeflow/skills/workflow/SKILL.md +0 -95
- package/plugins/freeflow/skills/workflow/references/workflow-map.md +0 -102
- package/plugins/freeflow/skills/write-plan/SKILL.md +0 -88
- package/plugins/freeflow/skills/write-plan/references/plan-shapes.md +0 -70
- package/plugins/freeflow/skills/write-skill/SKILL.md +0 -88
- package/plugins/freeflow/skills/write-spec/SKILL.md +0 -90
- package/plugins/freeflow/skills/write-spec/references/artifact-standards.md +0 -67
- package/plugins/freeflow/skills/write-spec/references/spec-shapes.md +0 -69
- /package/{plugins/freeflow/assets → assets}/freeflow-plugin-icon-transparent.png +0 -0
- /package/{plugins/freeflow/hooks → hooks}/hooks.json +0 -0
|
@@ -0,0 +1,4018 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
import freeflowExtension from "../../dist/index.js";
|
|
10
|
+
import {
|
|
11
|
+
readCapabilityState,
|
|
12
|
+
readFreeflowConfigLayers,
|
|
13
|
+
readModeState,
|
|
14
|
+
readOutputRouterConfig,
|
|
15
|
+
resetSessionOverrides,
|
|
16
|
+
restoreModeOverride,
|
|
17
|
+
setSessionCoreOverride,
|
|
18
|
+
setSessionMode,
|
|
19
|
+
} from "../../dist/runtime/runtime-context.js";
|
|
20
|
+
import { createVault, storeTextOutput } from "../../../router/dist/index.js";
|
|
21
|
+
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
function loadExtension() {
|
|
25
|
+
const handlers = new Map();
|
|
26
|
+
const tools = [];
|
|
27
|
+
const commands = [];
|
|
28
|
+
const entries = [];
|
|
29
|
+
const sentMessages = [];
|
|
30
|
+
let activeToolNames;
|
|
31
|
+
const pi = {
|
|
32
|
+
registerTool(tool) {
|
|
33
|
+
tools.push(tool);
|
|
34
|
+
},
|
|
35
|
+
registerCommand(name, definition) {
|
|
36
|
+
commands.push({ name, definition });
|
|
37
|
+
},
|
|
38
|
+
on(event, handler) {
|
|
39
|
+
handlers.set(event, handler);
|
|
40
|
+
},
|
|
41
|
+
appendEntry(customType, data) {
|
|
42
|
+
entries.push({ customType, data });
|
|
43
|
+
},
|
|
44
|
+
sendUserMessage(message) {
|
|
45
|
+
sentMessages.push(message);
|
|
46
|
+
},
|
|
47
|
+
getAllTools() {
|
|
48
|
+
return tools.map((tool) => ({
|
|
49
|
+
name: tool.name,
|
|
50
|
+
sourceInfo: { source: "extension" },
|
|
51
|
+
}));
|
|
52
|
+
},
|
|
53
|
+
getActiveTools() {
|
|
54
|
+
return activeToolNames ?? tools.map((tool) => tool.name);
|
|
55
|
+
},
|
|
56
|
+
setActiveTools(names) {
|
|
57
|
+
activeToolNames = [...names];
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
freeflowExtension(pi);
|
|
62
|
+
return {
|
|
63
|
+
pi,
|
|
64
|
+
handlers,
|
|
65
|
+
tools,
|
|
66
|
+
commands,
|
|
67
|
+
entries,
|
|
68
|
+
sentMessages,
|
|
69
|
+
activeToolNames: () => activeToolNames ?? tools.map((tool) => tool.name),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function context(cwd = process.cwd(), sessionEntries = [], activeSessionEntries = sessionEntries) {
|
|
74
|
+
const notifications = [];
|
|
75
|
+
const reloads = [];
|
|
76
|
+
const statuses = [];
|
|
77
|
+
return {
|
|
78
|
+
cwd,
|
|
79
|
+
notifications,
|
|
80
|
+
reloads,
|
|
81
|
+
statuses,
|
|
82
|
+
async reload() {
|
|
83
|
+
reloads.push(true);
|
|
84
|
+
},
|
|
85
|
+
sessionManager: {
|
|
86
|
+
getEntries() {
|
|
87
|
+
return sessionEntries;
|
|
88
|
+
},
|
|
89
|
+
getBranch() {
|
|
90
|
+
return activeSessionEntries;
|
|
91
|
+
},
|
|
92
|
+
buildContextEntries() {
|
|
93
|
+
return activeSessionEntries;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
ui: {
|
|
97
|
+
setStatus(name, value) {
|
|
98
|
+
statuses.push({ name, value });
|
|
99
|
+
},
|
|
100
|
+
notify(message, level) {
|
|
101
|
+
notifications.push({ message, level });
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const testTheme = {
|
|
108
|
+
fg(_color, text) {
|
|
109
|
+
return text;
|
|
110
|
+
},
|
|
111
|
+
bg(_color, text) {
|
|
112
|
+
return text;
|
|
113
|
+
},
|
|
114
|
+
bold(text) {
|
|
115
|
+
return text;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function renderText(component, width = 120) {
|
|
120
|
+
return component.render(width).join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
test("Pi registers capability commands and no public capture tool", () => {
|
|
124
|
+
const { commands, tools } = loadExtension();
|
|
125
|
+
const commandNames = commands.map((command) => command.name);
|
|
126
|
+
const toolNames = tools.map((tool) => tool.name);
|
|
127
|
+
|
|
128
|
+
assert.ok(commandNames.includes("freeflow"));
|
|
129
|
+
assert.ok(commandNames.includes("output-router"));
|
|
130
|
+
for (const command of ["discuss", "discover", "track-work", "execute-work", "execute-plan"]) {
|
|
131
|
+
assert.ok(commandNames.includes(command));
|
|
132
|
+
}
|
|
133
|
+
assert.ok(!commandNames.includes("workflow"));
|
|
134
|
+
assert.ok(toolNames.includes("freeflow_status"));
|
|
135
|
+
assert.ok(toolNames.includes("freeflow_search"));
|
|
136
|
+
assert.ok(toolNames.includes("freeflow_run"));
|
|
137
|
+
assert.ok(toolNames.includes("freeflow_batch"));
|
|
138
|
+
assert.ok(!toolNames.includes("freeflow_retrieve"));
|
|
139
|
+
assert.ok(!toolNames.includes("freeflow_search action=transform"));
|
|
140
|
+
assert.ok(!toolNames.includes("freeflow_capture"));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("Pi exposes bypass scope argument completions", () => {
|
|
144
|
+
const { commands } = loadExtension();
|
|
145
|
+
const bypassCommand = commands.find((command) => command.name === "bypass");
|
|
146
|
+
assert.ok(bypassCommand);
|
|
147
|
+
|
|
148
|
+
assert.deepEqual(bypassCommand.definition.getArgumentCompletions(""), [
|
|
149
|
+
{ value: "next", label: "next", description: "Skip one optional step" },
|
|
150
|
+
{ value: "task", label: "task", description: "Reduce optional pressure for the current task" },
|
|
151
|
+
]);
|
|
152
|
+
assert.deepEqual(bypassCommand.definition.getArgumentCompletions("t"), [
|
|
153
|
+
{ value: "task", label: "task", description: "Reduce optional pressure for the current task" },
|
|
154
|
+
]);
|
|
155
|
+
assert.deepEqual(bypassCommand.definition.getArgumentCompletions("unknown"), []);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("Pi describes Freeflow and Output Router argument completions", () => {
|
|
159
|
+
const { commands } = loadExtension();
|
|
160
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
161
|
+
const outputRouterCommand = commands.find((command) => command.name === "output-router");
|
|
162
|
+
assert.ok(freeflowCommand);
|
|
163
|
+
assert.ok(outputRouterCommand);
|
|
164
|
+
|
|
165
|
+
assert.deepEqual(freeflowCommand.definition.getArgumentCompletions(""), [
|
|
166
|
+
{ value: "settings", label: "settings", description: "Open personal override settings" },
|
|
167
|
+
{ value: "status", label: "status", description: "Show effective Freeflow state" },
|
|
168
|
+
{ value: "mode", label: "mode", description: "Select a temporary session mode" },
|
|
169
|
+
{ value: "enable", label: "enable", description: "Enable Freeflow for this repository" },
|
|
170
|
+
{ value: "disable", label: "disable", description: "Disable Freeflow for this repository" },
|
|
171
|
+
]);
|
|
172
|
+
assert.deepEqual(freeflowCommand.definition.getArgumentCompletions("settings "), [
|
|
173
|
+
{ value: "settings session", label: "session", description: "Override Freeflow for this Pi session" },
|
|
174
|
+
{ value: "settings local", label: "local", description: "Edit personal overrides for this repository" },
|
|
175
|
+
{ value: "settings repo", label: "repo", description: "Edit shared repository settings" },
|
|
176
|
+
]);
|
|
177
|
+
assert.deepEqual(freeflowCommand.definition.getArgumentCompletions("mode "), [
|
|
178
|
+
{ value: "mode conversation", label: "conversation", description: "Read-only discussion and inspection" },
|
|
179
|
+
{ value: "mode workflow", label: "workflow", description: "Adaptive workflow for consequential work" },
|
|
180
|
+
{
|
|
181
|
+
value: "mode strict-workflow",
|
|
182
|
+
label: "strict-workflow",
|
|
183
|
+
description: "Stronger pressure at high-risk boundaries",
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
value: "mode reset",
|
|
187
|
+
label: "reset",
|
|
188
|
+
description: "Clear the session override and use the configured default",
|
|
189
|
+
},
|
|
190
|
+
]);
|
|
191
|
+
assert.deepEqual(outputRouterCommand.definition.getArgumentCompletions(""), [
|
|
192
|
+
{ value: "settings", label: "settings", description: "Open repository Output Router settings" },
|
|
193
|
+
{ value: "status", label: "status", description: "Show effective Output Router state" },
|
|
194
|
+
{ value: "enable", label: "enable", description: "Enable Output Router for this repository" },
|
|
195
|
+
{ value: "disable", label: "disable", description: "Disable Output Router for this repository" },
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("Pi exposes canonical model skills and maps published command aliases", async () => {
|
|
200
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-canonical-skills-"));
|
|
201
|
+
try {
|
|
202
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
203
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
204
|
+
|
|
205
|
+
const { handlers, commands, sentMessages } = loadExtension();
|
|
206
|
+
const resources = await handlers.get("resources_discover")({ cwd }, context(cwd));
|
|
207
|
+
const skillNames = resources.skillPaths.map((path) => {
|
|
208
|
+
const match = path.match(/[\\/]skills[\\/]([^\\/]+)[\\/]SKILL\.md$/);
|
|
209
|
+
assert.ok(match, `unexpected skill path: ${path}`);
|
|
210
|
+
return match[1];
|
|
211
|
+
});
|
|
212
|
+
for (const skill of ["discuss", "execute-work", "track-work"]) {
|
|
213
|
+
assert.ok(skillNames.includes(skill));
|
|
214
|
+
}
|
|
215
|
+
assert.ok(!skillNames.includes("discover"));
|
|
216
|
+
assert.ok(!skillNames.includes("execute-plan"));
|
|
217
|
+
await Promise.all(resources.skillPaths.map((path) => readFile(path, "utf8")));
|
|
218
|
+
|
|
219
|
+
for (const [commandName, expectedSkill] of [
|
|
220
|
+
["discuss", "discuss"],
|
|
221
|
+
["discover", "discuss"],
|
|
222
|
+
["track-work", "track-work"],
|
|
223
|
+
["execute-work", "execute-work"],
|
|
224
|
+
["execute-plan", "execute-work"],
|
|
225
|
+
]) {
|
|
226
|
+
const command = commands.find((candidate) => candidate.name === commandName);
|
|
227
|
+
assert.ok(command);
|
|
228
|
+
await command.definition.handler(undefined, context(cwd));
|
|
229
|
+
assert.equal(sentMessages.at(-1), `/skill:${expectedSkill}`);
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
await rm(cwd, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("Pi keeps Freeflow inactive until setup config exists", async () => {
|
|
237
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-missing-setup-"));
|
|
238
|
+
try {
|
|
239
|
+
const { handlers, activeToolNames } = loadExtension();
|
|
240
|
+
const resourcesDiscover = handlers.get("resources_discover");
|
|
241
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
242
|
+
assert.ok(resourcesDiscover);
|
|
243
|
+
assert.ok(beforeAgentStart);
|
|
244
|
+
|
|
245
|
+
const resources = await resourcesDiscover({ cwd }, context(cwd));
|
|
246
|
+
assert.equal(resources.skillPaths.length, 1);
|
|
247
|
+
assert.match(resources.skillPaths[0], /setup-freeflow\/SKILL\.md$/);
|
|
248
|
+
|
|
249
|
+
const result = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
250
|
+
assert.equal(result.systemPrompt, "base prompt");
|
|
251
|
+
assert.ok(!activeToolNames().includes("freeflow_status"));
|
|
252
|
+
assert.ok(!activeToolNames().includes("freeflow_search"));
|
|
253
|
+
assert.ok(!activeToolNames().includes("freeflow_run"));
|
|
254
|
+
assert.ok(!activeToolNames().includes("freeflow_batch"));
|
|
255
|
+
} finally {
|
|
256
|
+
await rm(cwd, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("Pi treats invalid Freeflow setup config as inactive", async () => {
|
|
261
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-invalid-setup-"));
|
|
262
|
+
try {
|
|
263
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
264
|
+
await writeFile(
|
|
265
|
+
join(cwd, ".freeflow/config.json"),
|
|
266
|
+
JSON.stringify(
|
|
267
|
+
{
|
|
268
|
+
defaultMode: "workflow",
|
|
269
|
+
enabled: "false",
|
|
270
|
+
interactionContract: "on",
|
|
271
|
+
skills: { enabled: "no" },
|
|
272
|
+
outputRouter: { enabled: true },
|
|
273
|
+
},
|
|
274
|
+
null,
|
|
275
|
+
2,
|
|
276
|
+
),
|
|
277
|
+
"utf8",
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const { handlers, commands, activeToolNames } = loadExtension();
|
|
281
|
+
const resources = await handlers.get("resources_discover")({ cwd }, context(cwd));
|
|
282
|
+
assert.equal(resources.skillPaths.length, 1);
|
|
283
|
+
assert.match(resources.skillPaths[0], /setup-freeflow\/SKILL\.md$/);
|
|
284
|
+
|
|
285
|
+
const result = await handlers.get("before_agent_start")({ systemPrompt: "base prompt" }, context(cwd));
|
|
286
|
+
assert.equal(result.systemPrompt, "base prompt");
|
|
287
|
+
assert.ok(!activeToolNames().includes("freeflow_status"));
|
|
288
|
+
assert.ok(!activeToolNames().includes("freeflow_search"));
|
|
289
|
+
|
|
290
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
291
|
+
const statusCtx = context(cwd);
|
|
292
|
+
await freeflowCommand.definition.handler("status", statusCtx);
|
|
293
|
+
assert.match(statusCtx.notifications.at(-1).message, /invalid config/);
|
|
294
|
+
} finally {
|
|
295
|
+
await rm(cwd, { recursive: true, force: true });
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("Pi rejects unsupported nested skills keys in repository and local config", async () => {
|
|
300
|
+
for (const configFile of ["config.json", "local.json"]) {
|
|
301
|
+
const cwd = await mkdtemp(join(tmpdir(), `freeflow-pi-unsupported-skills-${configFile}-`));
|
|
302
|
+
try {
|
|
303
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
304
|
+
if (configFile === "local.json") {
|
|
305
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }), "utf8");
|
|
306
|
+
}
|
|
307
|
+
await writeFile(join(cwd, `.freeflow/${configFile}`), JSON.stringify({ skills: { enable: false } }), "utf8");
|
|
308
|
+
|
|
309
|
+
const layers = await readFreeflowConfigLayers(cwd);
|
|
310
|
+
const invalidLayer = configFile === "config.json" ? layers.repository : layers.local;
|
|
311
|
+
assert.equal(invalidLayer.valid, false, `${configFile} should reject unknown nested skills keys`);
|
|
312
|
+
assert.equal(layers.configured, false);
|
|
313
|
+
assert.equal(layers.parseError, "unsupported skills config key: enable");
|
|
314
|
+
} finally {
|
|
315
|
+
await rm(cwd, { recursive: true, force: true });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("Pi layers local core overrides over repository defaults with source evidence", async () => {
|
|
321
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-layered-core-"));
|
|
322
|
+
try {
|
|
323
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
324
|
+
await writeFile(
|
|
325
|
+
join(cwd, ".freeflow/config.json"),
|
|
326
|
+
JSON.stringify(
|
|
327
|
+
{
|
|
328
|
+
enabled: false,
|
|
329
|
+
interactionContract: false,
|
|
330
|
+
skills: { enabled: true },
|
|
331
|
+
defaultMode: "workflow",
|
|
332
|
+
},
|
|
333
|
+
null,
|
|
334
|
+
2,
|
|
335
|
+
),
|
|
336
|
+
"utf8",
|
|
337
|
+
);
|
|
338
|
+
await writeFile(
|
|
339
|
+
join(cwd, ".freeflow/local.json"),
|
|
340
|
+
JSON.stringify(
|
|
341
|
+
{
|
|
342
|
+
enabled: true,
|
|
343
|
+
skills: { enabled: false },
|
|
344
|
+
defaultMode: "strict-workflow",
|
|
345
|
+
processing: { unsafeUnsandboxed: { enabled: true } },
|
|
346
|
+
},
|
|
347
|
+
null,
|
|
348
|
+
2,
|
|
349
|
+
),
|
|
350
|
+
"utf8",
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const layers = await readFreeflowConfigLayers(cwd);
|
|
354
|
+
assert.equal(layers.configured, true);
|
|
355
|
+
assert.equal(layers.repositoryConfigured, true);
|
|
356
|
+
assert.deepEqual(layers.coreConfig, {
|
|
357
|
+
enabled: true,
|
|
358
|
+
interactionContract: false,
|
|
359
|
+
skills: { enabled: false },
|
|
360
|
+
defaultMode: "strict-workflow",
|
|
361
|
+
});
|
|
362
|
+
assert.deepEqual(layers.sources, {
|
|
363
|
+
enabled: "local",
|
|
364
|
+
interactionContract: "repository",
|
|
365
|
+
skillsEnabled: "local",
|
|
366
|
+
defaultMode: "local",
|
|
367
|
+
});
|
|
368
|
+
assert.equal(layers.local.parsed.processing.unsafeUnsandboxed.enabled, true);
|
|
369
|
+
const routerConfig = await readOutputRouterConfig(cwd);
|
|
370
|
+
assert.equal(routerConfig.localConfig.processing.unsafeUnsandboxed.enabled, true);
|
|
371
|
+
|
|
372
|
+
const capabilityState = await readCapabilityState(cwd);
|
|
373
|
+
assert.equal(capabilityState.enabled, true);
|
|
374
|
+
assert.equal(capabilityState.interactionContract.effective, false);
|
|
375
|
+
assert.equal(capabilityState.skills.effective, false);
|
|
376
|
+
assert.deepEqual(capabilityState.configSources, layers.sources);
|
|
377
|
+
|
|
378
|
+
const modeState = await readModeState(cwd);
|
|
379
|
+
assert.equal(modeState.repositoryDefaultMode, "workflow");
|
|
380
|
+
assert.equal(modeState.repositoryDefaultModeSource, "repository");
|
|
381
|
+
assert.equal(modeState.personalDefaultMode, "strict-workflow");
|
|
382
|
+
assert.equal(modeState.defaultMode, "strict-workflow");
|
|
383
|
+
assert.equal(modeState.defaultModeSource, "local");
|
|
384
|
+
assert.equal(modeState.sessionMode, null);
|
|
385
|
+
assert.equal(modeState.resolvedMode, "strict-workflow");
|
|
386
|
+
assert.equal(modeState.active, false);
|
|
387
|
+
assert.equal(modeState.effectiveMode, null);
|
|
388
|
+
} finally {
|
|
389
|
+
await rm(cwd, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("Pi layers branch-aware session core overrides above configured values", async () => {
|
|
394
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-session-core-"));
|
|
395
|
+
try {
|
|
396
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
397
|
+
const configPath = join(cwd, ".freeflow/config.json");
|
|
398
|
+
const configText = JSON.stringify({ defaultMode: "workflow" }, null, 2);
|
|
399
|
+
await writeFile(configPath, configText, "utf8");
|
|
400
|
+
|
|
401
|
+
const { pi, entries } = loadExtension();
|
|
402
|
+
const ctx = context(cwd);
|
|
403
|
+
|
|
404
|
+
let result = await setSessionCoreOverride("enabled", false, ctx, pi);
|
|
405
|
+
assert.equal(result.changed, true);
|
|
406
|
+
let capabilityState = await readCapabilityState(cwd);
|
|
407
|
+
assert.equal(capabilityState.enabled, false);
|
|
408
|
+
assert.equal(capabilityState.configSources.enabled, "session");
|
|
409
|
+
assert.deepEqual(capabilityState.sessionOverrides, { enabled: false });
|
|
410
|
+
assert.deepEqual(entries.at(-1), {
|
|
411
|
+
customType: "freeflow-session-overrides",
|
|
412
|
+
data: { overrides: { enabled: false } },
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
result = await setSessionCoreOverride("enabled", false, ctx, pi);
|
|
416
|
+
assert.equal(result.changed, false);
|
|
417
|
+
assert.equal(entries.length, 1);
|
|
418
|
+
|
|
419
|
+
await setSessionCoreOverride("enabled", null, ctx, pi);
|
|
420
|
+
await setSessionCoreOverride("interactionContract", false, ctx, pi);
|
|
421
|
+
await setSessionCoreOverride("skillsEnabled", false, ctx, pi);
|
|
422
|
+
capabilityState = await readCapabilityState(cwd);
|
|
423
|
+
assert.equal(capabilityState.enabled, true);
|
|
424
|
+
assert.equal(capabilityState.interactionContract.effective, false);
|
|
425
|
+
assert.equal(capabilityState.skills.effective, false);
|
|
426
|
+
assert.equal((await readModeState(cwd)).effectiveMode, null);
|
|
427
|
+
|
|
428
|
+
await setSessionMode("conversation", ctx, pi);
|
|
429
|
+
const reset = await resetSessionOverrides(ctx, pi);
|
|
430
|
+
assert.equal(reset.changed, true);
|
|
431
|
+
assert.equal(reset.reloadRequired, true);
|
|
432
|
+
capabilityState = await readCapabilityState(cwd);
|
|
433
|
+
assert.deepEqual(capabilityState.sessionOverrides, {});
|
|
434
|
+
assert.equal(capabilityState.interactionContract.effective, true);
|
|
435
|
+
assert.equal(capabilityState.skills.effective, true);
|
|
436
|
+
assert.equal((await readModeState(cwd)).sessionMode, null);
|
|
437
|
+
assert.equal(await readFile(configPath, "utf8"), configText);
|
|
438
|
+
} finally {
|
|
439
|
+
restoreModeOverride(context(cwd));
|
|
440
|
+
await rm(cwd, { recursive: true, force: true });
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("Pi session enablement can override configured off but cannot bypass activation", async () => {
|
|
445
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-session-activation-"));
|
|
446
|
+
try {
|
|
447
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
448
|
+
const { pi } = loadExtension();
|
|
449
|
+
const ctx = context(cwd);
|
|
450
|
+
await setSessionCoreOverride("enabled", true, ctx, pi);
|
|
451
|
+
|
|
452
|
+
let capabilityState = await readCapabilityState(cwd);
|
|
453
|
+
assert.equal(capabilityState.configured, false);
|
|
454
|
+
assert.equal(capabilityState.enabled, false);
|
|
455
|
+
|
|
456
|
+
await writeFile(join(cwd, ".freeflow/config.json"), "{ invalid", "utf8");
|
|
457
|
+
capabilityState = await readCapabilityState(cwd);
|
|
458
|
+
assert.equal(capabilityState.configured, false);
|
|
459
|
+
assert.equal(capabilityState.enabled, false);
|
|
460
|
+
|
|
461
|
+
await writeFile(
|
|
462
|
+
join(cwd, ".freeflow/config.json"),
|
|
463
|
+
JSON.stringify({ enabled: false, defaultMode: "workflow" }),
|
|
464
|
+
"utf8",
|
|
465
|
+
);
|
|
466
|
+
capabilityState = await readCapabilityState(cwd);
|
|
467
|
+
assert.equal(capabilityState.configured, true);
|
|
468
|
+
assert.equal(capabilityState.enabled, true);
|
|
469
|
+
assert.equal(capabilityState.configSources.enabled, "session");
|
|
470
|
+
} finally {
|
|
471
|
+
restoreModeOverride(context(cwd));
|
|
472
|
+
await rm(cwd, { recursive: true, force: true });
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("Pi restores session core overrides from the active branch", async () => {
|
|
477
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-session-core-branch-"));
|
|
478
|
+
try {
|
|
479
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
480
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }), "utf8");
|
|
481
|
+
const activeBranchEntries = [
|
|
482
|
+
{
|
|
483
|
+
type: "custom",
|
|
484
|
+
customType: "freeflow-session-overrides",
|
|
485
|
+
data: { overrides: { interactionContract: false } },
|
|
486
|
+
},
|
|
487
|
+
];
|
|
488
|
+
const allEntries = [
|
|
489
|
+
...activeBranchEntries,
|
|
490
|
+
{
|
|
491
|
+
type: "custom",
|
|
492
|
+
customType: "freeflow-session-overrides",
|
|
493
|
+
data: { overrides: { enabled: false } },
|
|
494
|
+
},
|
|
495
|
+
];
|
|
496
|
+
const { handlers } = loadExtension();
|
|
497
|
+
await handlers.get("session_start")({ reason: "resume" }, context(cwd, allEntries, activeBranchEntries));
|
|
498
|
+
|
|
499
|
+
let capabilityState = await readCapabilityState(cwd);
|
|
500
|
+
assert.equal(capabilityState.enabled, true);
|
|
501
|
+
assert.equal(capabilityState.interactionContract.effective, false);
|
|
502
|
+
assert.deepEqual(capabilityState.sessionOverrides, { interactionContract: false });
|
|
503
|
+
|
|
504
|
+
const switchedBranchEntries = [
|
|
505
|
+
{
|
|
506
|
+
type: "custom",
|
|
507
|
+
customType: "freeflow-session-overrides",
|
|
508
|
+
data: { overrides: { enabled: false } },
|
|
509
|
+
},
|
|
510
|
+
];
|
|
511
|
+
await handlers.get("session_tree")({}, context(cwd, allEntries, switchedBranchEntries));
|
|
512
|
+
capabilityState = await readCapabilityState(cwd);
|
|
513
|
+
assert.equal(capabilityState.enabled, false);
|
|
514
|
+
assert.deepEqual(capabilityState.sessionOverrides, { enabled: false });
|
|
515
|
+
} finally {
|
|
516
|
+
restoreModeOverride(context(cwd));
|
|
517
|
+
await rm(cwd, { recursive: true, force: true });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("Pi compact mode context distinguishes layered defaults, session state, and effective mode", async () => {
|
|
522
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-layered-mode-context-"));
|
|
523
|
+
try {
|
|
524
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
525
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
526
|
+
await writeFile(
|
|
527
|
+
join(cwd, ".freeflow/local.json"),
|
|
528
|
+
JSON.stringify({ defaultMode: "strict-workflow" }, null, 2),
|
|
529
|
+
"utf8",
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
const { handlers, commands } = loadExtension();
|
|
533
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
534
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
535
|
+
assert.ok(beforeAgentStart);
|
|
536
|
+
assert.ok(freeflowCommand);
|
|
537
|
+
|
|
538
|
+
let result = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
539
|
+
assert.match(result.systemPrompt, /Repository default mode: `workflow` \(repository\)/);
|
|
540
|
+
assert.match(result.systemPrompt, /Personal default override: `strict-workflow`/);
|
|
541
|
+
assert.match(result.systemPrompt, /Configured default mode: `strict-workflow` \(personal override\)/);
|
|
542
|
+
assert.match(result.systemPrompt, /Session mode override: `none`/);
|
|
543
|
+
assert.match(result.systemPrompt, /Resolved mode: `strict-workflow`/);
|
|
544
|
+
assert.match(result.systemPrompt, /Effective Freeflow mode: `strict-workflow`/);
|
|
545
|
+
assert.match(result.systemPrompt, /direct skill calls do not change mode/);
|
|
546
|
+
assert.match(result.systemPrompt, /## Strict Workflow Overlay/);
|
|
547
|
+
assert.match(result.systemPrompt, /security, privacy, billing, data loss, migrations, public interfaces/);
|
|
548
|
+
assert.doesNotMatch(result.systemPrompt, /## Conversation Mode Boundary/);
|
|
549
|
+
|
|
550
|
+
await freeflowCommand.definition.handler("mode conversation", context(cwd));
|
|
551
|
+
result = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
552
|
+
assert.match(result.systemPrompt, /Session mode override: `conversation`/);
|
|
553
|
+
assert.match(result.systemPrompt, /Resolved mode: `conversation`/);
|
|
554
|
+
assert.match(result.systemPrompt, /Effective Freeflow mode: `conversation`/);
|
|
555
|
+
assert.match(result.systemPrompt, /## Conversation Mode Boundary/);
|
|
556
|
+
assert.match(result.systemPrompt, /Do not call write, edit, or mutating tools/);
|
|
557
|
+
assert.match(result.systemPrompt, /an execution skill does not override this boundary/);
|
|
558
|
+
assert.doesNotMatch(result.systemPrompt, /## Strict Workflow Overlay/);
|
|
559
|
+
|
|
560
|
+
await freeflowCommand.definition.handler("mode reset", context(cwd));
|
|
561
|
+
} finally {
|
|
562
|
+
await rm(cwd, { recursive: true, force: true });
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("Pi restores layered session mode on resume and preserves it through compaction", async () => {
|
|
567
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-mode-lifecycle-"));
|
|
568
|
+
try {
|
|
569
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
570
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
571
|
+
const localPath = join(cwd, ".freeflow/local.json");
|
|
572
|
+
await writeFile(localPath, JSON.stringify({ defaultMode: "strict-workflow" }, null, 2), "utf8");
|
|
573
|
+
|
|
574
|
+
const sessionEntries = [
|
|
575
|
+
{
|
|
576
|
+
type: "custom",
|
|
577
|
+
customType: "freeflow-mode",
|
|
578
|
+
data: { currentMode: "conversation" },
|
|
579
|
+
},
|
|
580
|
+
];
|
|
581
|
+
const { handlers } = loadExtension();
|
|
582
|
+
const sessionStart = handlers.get("session_start");
|
|
583
|
+
const sessionCompact = handlers.get("session_compact");
|
|
584
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
585
|
+
assert.ok(sessionStart);
|
|
586
|
+
assert.ok(sessionCompact);
|
|
587
|
+
assert.ok(beforeAgentStart);
|
|
588
|
+
|
|
589
|
+
const ctx = context(cwd, sessionEntries);
|
|
590
|
+
await sessionStart({ reason: "resume" }, ctx);
|
|
591
|
+
let modeState = await readModeState(cwd);
|
|
592
|
+
assert.equal(modeState.repositoryDefaultMode, "workflow");
|
|
593
|
+
assert.equal(modeState.personalDefaultMode, "strict-workflow");
|
|
594
|
+
assert.equal(modeState.sessionMode, "conversation");
|
|
595
|
+
assert.equal(modeState.resolvedMode, "conversation");
|
|
596
|
+
assert.equal(modeState.effectiveMode, "conversation");
|
|
597
|
+
|
|
598
|
+
await sessionCompact({ reason: "manual" }, ctx);
|
|
599
|
+
let result = await beforeAgentStart({ prompt: "continue", systemPrompt: "base prompt" }, ctx);
|
|
600
|
+
assert.match(result.systemPrompt, /Session mode override: `conversation`/);
|
|
601
|
+
assert.match(result.systemPrompt, /Resolved mode: `conversation`/);
|
|
602
|
+
assert.match(result.systemPrompt, /Effective Freeflow mode: `conversation`/);
|
|
603
|
+
|
|
604
|
+
await writeFile(
|
|
605
|
+
localPath,
|
|
606
|
+
JSON.stringify(
|
|
607
|
+
{
|
|
608
|
+
defaultMode: "strict-workflow",
|
|
609
|
+
skills: { enabled: false },
|
|
610
|
+
},
|
|
611
|
+
null,
|
|
612
|
+
2,
|
|
613
|
+
),
|
|
614
|
+
"utf8",
|
|
615
|
+
);
|
|
616
|
+
await sessionCompact({ reason: "manual" }, ctx);
|
|
617
|
+
modeState = await readModeState(cwd);
|
|
618
|
+
assert.equal(modeState.sessionMode, "conversation");
|
|
619
|
+
assert.equal(modeState.resolvedMode, "conversation");
|
|
620
|
+
assert.equal(modeState.active, false);
|
|
621
|
+
assert.equal(modeState.effectiveMode, null);
|
|
622
|
+
result = await beforeAgentStart({ prompt: "continue", systemPrompt: "base prompt" }, ctx);
|
|
623
|
+
assert.match(result.systemPrompt, /Session mode override: `conversation`/);
|
|
624
|
+
assert.match(result.systemPrompt, /Resolved mode: `conversation` \(inactive because Skills are disabled\)/);
|
|
625
|
+
assert.match(result.systemPrompt, /Effective Freeflow mode: `none`/);
|
|
626
|
+
|
|
627
|
+
sessionEntries.push({
|
|
628
|
+
type: "custom",
|
|
629
|
+
customType: "freeflow-mode",
|
|
630
|
+
data: { currentMode: null },
|
|
631
|
+
});
|
|
632
|
+
await sessionStart({ reason: "resume" }, ctx);
|
|
633
|
+
modeState = await readModeState(cwd);
|
|
634
|
+
assert.equal(modeState.sessionMode, null);
|
|
635
|
+
assert.equal(modeState.resolvedMode, "strict-workflow");
|
|
636
|
+
assert.equal(modeState.active, false);
|
|
637
|
+
assert.equal(modeState.effectiveMode, null);
|
|
638
|
+
} finally {
|
|
639
|
+
await rm(cwd, { recursive: true, force: true });
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("Pi restores session mode from the active branch on resume and tree navigation", async () => {
|
|
644
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-mode-active-branch-"));
|
|
645
|
+
try {
|
|
646
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
647
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
648
|
+
|
|
649
|
+
const activeBranchEntries = [
|
|
650
|
+
{
|
|
651
|
+
type: "custom",
|
|
652
|
+
customType: "freeflow-mode",
|
|
653
|
+
data: { currentMode: "conversation" },
|
|
654
|
+
},
|
|
655
|
+
];
|
|
656
|
+
const allSessionEntries = [
|
|
657
|
+
...activeBranchEntries,
|
|
658
|
+
{
|
|
659
|
+
type: "custom",
|
|
660
|
+
customType: "freeflow-mode",
|
|
661
|
+
data: { currentMode: "strict-workflow" },
|
|
662
|
+
},
|
|
663
|
+
];
|
|
664
|
+
const { handlers } = loadExtension();
|
|
665
|
+
const sessionStart = handlers.get("session_start");
|
|
666
|
+
const sessionTree = handlers.get("session_tree");
|
|
667
|
+
assert.ok(sessionStart);
|
|
668
|
+
assert.ok(sessionTree);
|
|
669
|
+
|
|
670
|
+
const ctx = context(cwd, allSessionEntries, activeBranchEntries);
|
|
671
|
+
await sessionStart({ reason: "resume" }, ctx);
|
|
672
|
+
|
|
673
|
+
let modeState = await readModeState(cwd);
|
|
674
|
+
assert.equal(modeState.sessionMode, "conversation");
|
|
675
|
+
assert.equal(modeState.effectiveMode, "conversation");
|
|
676
|
+
|
|
677
|
+
activeBranchEntries.splice(0, activeBranchEntries.length, allSessionEntries.at(-1));
|
|
678
|
+
await sessionTree({ newLeafId: "strict-branch" }, ctx);
|
|
679
|
+
|
|
680
|
+
modeState = await readModeState(cwd);
|
|
681
|
+
assert.equal(modeState.sessionMode, "strict-workflow");
|
|
682
|
+
assert.equal(modeState.effectiveMode, "strict-workflow");
|
|
683
|
+
} finally {
|
|
684
|
+
restoreModeOverride(context(cwd));
|
|
685
|
+
await rm(cwd, { recursive: true, force: true });
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("Pi ordinary prompts and direct skill calls do not change mode state", async () => {
|
|
690
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-mode-boundaries-"));
|
|
691
|
+
try {
|
|
692
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
693
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
694
|
+
await writeFile(
|
|
695
|
+
join(cwd, ".freeflow/local.json"),
|
|
696
|
+
JSON.stringify({ defaultMode: "strict-workflow" }, null, 2),
|
|
697
|
+
"utf8",
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
const { handlers, commands, entries, sentMessages } = loadExtension();
|
|
701
|
+
const sessionStart = handlers.get("session_start");
|
|
702
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
703
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
704
|
+
const commitWorkCommand = commands.find((command) => command.name === "commit-work");
|
|
705
|
+
assert.ok(sessionStart);
|
|
706
|
+
assert.ok(beforeAgentStart);
|
|
707
|
+
assert.ok(freeflowCommand);
|
|
708
|
+
assert.ok(commitWorkCommand);
|
|
709
|
+
|
|
710
|
+
await sessionStart({ reason: "startup" }, context(cwd));
|
|
711
|
+
let result = await beforeAgentStart(
|
|
712
|
+
{
|
|
713
|
+
prompt: "Switch to conversation mode and implement this change.",
|
|
714
|
+
systemPrompt: "base prompt",
|
|
715
|
+
},
|
|
716
|
+
context(cwd),
|
|
717
|
+
);
|
|
718
|
+
let modeState = await readModeState(cwd);
|
|
719
|
+
assert.equal(modeState.sessionMode, null);
|
|
720
|
+
assert.equal(modeState.resolvedMode, "strict-workflow");
|
|
721
|
+
assert.equal(modeState.effectiveMode, "strict-workflow");
|
|
722
|
+
assert.match(result.systemPrompt, /Task type, risk classification, and direct skill calls do not change mode/);
|
|
723
|
+
assert.deepEqual(entries, []);
|
|
724
|
+
|
|
725
|
+
await freeflowCommand.definition.handler("mode conversation", context(cwd));
|
|
726
|
+
assert.equal(entries.length, 1);
|
|
727
|
+
await commitWorkCommand.definition.handler("create a checkpoint", context(cwd));
|
|
728
|
+
assert.deepEqual(sentMessages, ["/skill:commit-work\n\ncreate a checkpoint"]);
|
|
729
|
+
assert.equal(entries.length, 1);
|
|
730
|
+
|
|
731
|
+
modeState = await readModeState(cwd);
|
|
732
|
+
assert.equal(modeState.sessionMode, "conversation");
|
|
733
|
+
assert.equal(modeState.resolvedMode, "conversation");
|
|
734
|
+
assert.equal(modeState.effectiveMode, "conversation");
|
|
735
|
+
result = await beforeAgentStart({ prompt: "/commit-work", systemPrompt: "base prompt" }, context(cwd));
|
|
736
|
+
assert.match(result.systemPrompt, /Session mode override: `conversation`/);
|
|
737
|
+
assert.match(result.systemPrompt, /Effective Freeflow mode: `conversation`/);
|
|
738
|
+
} finally {
|
|
739
|
+
restoreModeOverride(context(cwd));
|
|
740
|
+
await rm(cwd, { recursive: true, force: true });
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("Pi layered core config inherits omitted values and built-in defaults", async () => {
|
|
745
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-layered-inherit-"));
|
|
746
|
+
try {
|
|
747
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
748
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({}, null, 2), "utf8");
|
|
749
|
+
await writeFile(join(cwd, ".freeflow/local.json"), JSON.stringify({ interactionContract: false }, null, 2), "utf8");
|
|
750
|
+
|
|
751
|
+
const layers = await readFreeflowConfigLayers(cwd);
|
|
752
|
+
assert.deepEqual(layers.coreConfig, {
|
|
753
|
+
enabled: true,
|
|
754
|
+
interactionContract: false,
|
|
755
|
+
skills: { enabled: true },
|
|
756
|
+
defaultMode: "workflow",
|
|
757
|
+
});
|
|
758
|
+
assert.deepEqual(layers.sources, {
|
|
759
|
+
enabled: "builtin",
|
|
760
|
+
interactionContract: "local",
|
|
761
|
+
skillsEnabled: "builtin",
|
|
762
|
+
defaultMode: "builtin",
|
|
763
|
+
});
|
|
764
|
+
} finally {
|
|
765
|
+
await rm(cwd, { recursive: true, force: true });
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test("Pi requires repository activation even when local overrides exist", async () => {
|
|
770
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-local-only-"));
|
|
771
|
+
try {
|
|
772
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
773
|
+
await writeFile(
|
|
774
|
+
join(cwd, ".freeflow/local.json"),
|
|
775
|
+
JSON.stringify({ enabled: true, defaultMode: "strict-workflow" }, null, 2),
|
|
776
|
+
"utf8",
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
const layers = await readFreeflowConfigLayers(cwd);
|
|
780
|
+
assert.equal(layers.repositoryConfigured, false);
|
|
781
|
+
assert.equal(layers.configured, false);
|
|
782
|
+
const capabilityState = await readCapabilityState(cwd);
|
|
783
|
+
assert.equal(capabilityState.enabled, false);
|
|
784
|
+
assert.equal(capabilityState.skills.effective, false);
|
|
785
|
+
|
|
786
|
+
const { handlers } = loadExtension();
|
|
787
|
+
const resources = await handlers.get("resources_discover")({ cwd }, context(cwd));
|
|
788
|
+
assert.equal(resources.skillPaths.length, 1);
|
|
789
|
+
assert.match(resources.skillPaths[0], /setup-freeflow\/SKILL\.md$/);
|
|
790
|
+
} finally {
|
|
791
|
+
await rm(cwd, { recursive: true, force: true });
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("Pi fails closed when an existing local override is invalid", async () => {
|
|
796
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-invalid-local-"));
|
|
797
|
+
try {
|
|
798
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
799
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
800
|
+
await writeFile(join(cwd, ".freeflow/local.json"), "{ invalid\n", "utf8");
|
|
801
|
+
|
|
802
|
+
const layers = await readFreeflowConfigLayers(cwd);
|
|
803
|
+
assert.equal(layers.repositoryConfigured, true);
|
|
804
|
+
assert.equal(layers.configured, false);
|
|
805
|
+
assert.equal(layers.local.exists, true);
|
|
806
|
+
assert.equal(layers.local.valid, false);
|
|
807
|
+
assert.equal(layers.blockingConfigPath, layers.local.path);
|
|
808
|
+
assert.match(layers.parseError, /JSON/);
|
|
809
|
+
|
|
810
|
+
const capabilityState = await readCapabilityState(cwd);
|
|
811
|
+
assert.equal(capabilityState.configured, false);
|
|
812
|
+
assert.equal(capabilityState.enabled, false);
|
|
813
|
+
assert.equal(capabilityState.interactionContract.effective, false);
|
|
814
|
+
assert.equal(capabilityState.skills.effective, false);
|
|
815
|
+
assert.equal(capabilityState.localConfigValid, false);
|
|
816
|
+
assert.match(capabilityState.localConfigParseError, /JSON/);
|
|
817
|
+
|
|
818
|
+
const { handlers, activeToolNames } = loadExtension();
|
|
819
|
+
const resources = await handlers.get("resources_discover")({ cwd }, context(cwd));
|
|
820
|
+
assert.equal(resources.skillPaths.length, 1);
|
|
821
|
+
assert.match(resources.skillPaths[0], /setup-freeflow\/SKILL\.md$/);
|
|
822
|
+
const result = await handlers.get("before_agent_start")({ systemPrompt: "base prompt" }, context(cwd));
|
|
823
|
+
assert.equal(result.systemPrompt, "base prompt");
|
|
824
|
+
assert.ok(!activeToolNames().includes("freeflow_status"));
|
|
825
|
+
} finally {
|
|
826
|
+
await rm(cwd, { recursive: true, force: true });
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test("Pi fails closed when a local core override has an invalid type", async () => {
|
|
831
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-invalid-local-shape-"));
|
|
832
|
+
try {
|
|
833
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
834
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
835
|
+
await writeFile(join(cwd, ".freeflow/local.json"), JSON.stringify({ skills: { enabled: "no" } }, null, 2), "utf8");
|
|
836
|
+
|
|
837
|
+
const layers = await readFreeflowConfigLayers(cwd);
|
|
838
|
+
assert.equal(layers.configured, false);
|
|
839
|
+
assert.equal(layers.local.valid, false);
|
|
840
|
+
assert.equal(layers.parseError, "skills.enabled must be a boolean");
|
|
841
|
+
const state = await readCapabilityState(cwd);
|
|
842
|
+
assert.equal(state.enabled, false);
|
|
843
|
+
assert.equal(state.skills.effective, false);
|
|
844
|
+
} finally {
|
|
845
|
+
await rm(cwd, { recursive: true, force: true });
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("Pi local enabled false overrides an enabled repository master switch", async () => {
|
|
850
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-local-disabled-"));
|
|
851
|
+
try {
|
|
852
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
853
|
+
await writeFile(
|
|
854
|
+
join(cwd, ".freeflow/config.json"),
|
|
855
|
+
JSON.stringify(
|
|
856
|
+
{
|
|
857
|
+
enabled: true,
|
|
858
|
+
defaultMode: "workflow",
|
|
859
|
+
outputRouter: { enabled: true },
|
|
860
|
+
},
|
|
861
|
+
null,
|
|
862
|
+
2,
|
|
863
|
+
),
|
|
864
|
+
"utf8",
|
|
865
|
+
);
|
|
866
|
+
await writeFile(join(cwd, ".freeflow/local.json"), JSON.stringify({ enabled: false }, null, 2), "utf8");
|
|
867
|
+
|
|
868
|
+
const state = await readCapabilityState(cwd);
|
|
869
|
+
assert.equal(state.configured, true);
|
|
870
|
+
assert.equal(state.enabled, false);
|
|
871
|
+
assert.equal(state.configSources.enabled, "local");
|
|
872
|
+
assert.equal(state.outputRouter.enabled, false);
|
|
873
|
+
} finally {
|
|
874
|
+
await rm(cwd, { recursive: true, force: true });
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("Pi master Freeflow toggle disables skills, capabilities, and routing", async () => {
|
|
879
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-master-disabled-"));
|
|
880
|
+
try {
|
|
881
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
882
|
+
await writeFile(
|
|
883
|
+
join(cwd, ".freeflow/config.json"),
|
|
884
|
+
JSON.stringify(
|
|
885
|
+
{
|
|
886
|
+
enabled: false,
|
|
887
|
+
defaultMode: "workflow",
|
|
888
|
+
skills: { enabled: true },
|
|
889
|
+
outputRouter: { enabled: true, postToolRouting: "safety-net" },
|
|
890
|
+
},
|
|
891
|
+
null,
|
|
892
|
+
2,
|
|
893
|
+
),
|
|
894
|
+
"utf8",
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
const { handlers, activeToolNames } = loadExtension();
|
|
898
|
+
const resources = await handlers.get("resources_discover")({ cwd }, context(cwd));
|
|
899
|
+
assert.deepEqual(resources.skillPaths, []);
|
|
900
|
+
|
|
901
|
+
const result = await handlers.get("before_agent_start")({ systemPrompt: "base prompt" }, context(cwd));
|
|
902
|
+
assert.match(result.systemPrompt, /# Freeflow Disabled/);
|
|
903
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Interaction Contract/);
|
|
904
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Runtime Kernel/);
|
|
905
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Workflow Skill/);
|
|
906
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Output Router Skill/);
|
|
907
|
+
assert.ok(!activeToolNames().includes("freeflow_status"));
|
|
908
|
+
assert.ok(!activeToolNames().includes("freeflow_search"));
|
|
909
|
+
|
|
910
|
+
const routed = await handlers.get("tool_result")(
|
|
911
|
+
{
|
|
912
|
+
toolName: "read",
|
|
913
|
+
input: { path: "large.txt" },
|
|
914
|
+
content: [{ type: "text", text: "line 1\nline 2" }],
|
|
915
|
+
isError: false,
|
|
916
|
+
},
|
|
917
|
+
context(cwd),
|
|
918
|
+
);
|
|
919
|
+
assert.equal(routed, undefined);
|
|
920
|
+
} finally {
|
|
921
|
+
await rm(cwd, { recursive: true, force: true });
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test("Pi skills toggle suppresses workflow skills while allowing enabled router tools", async () => {
|
|
926
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-skills-disabled-router-on-"));
|
|
927
|
+
try {
|
|
928
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
929
|
+
await writeFile(
|
|
930
|
+
join(cwd, ".freeflow/config.json"),
|
|
931
|
+
JSON.stringify(
|
|
932
|
+
{
|
|
933
|
+
defaultMode: "workflow",
|
|
934
|
+
skills: { enabled: false },
|
|
935
|
+
outputRouter: { enabled: true },
|
|
936
|
+
},
|
|
937
|
+
null,
|
|
938
|
+
2,
|
|
939
|
+
),
|
|
940
|
+
"utf8",
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
const { handlers, activeToolNames } = loadExtension();
|
|
944
|
+
const resources = await handlers.get("resources_discover")({ cwd }, context(cwd));
|
|
945
|
+
assert.deepEqual(resources.skillPaths, []);
|
|
946
|
+
|
|
947
|
+
const ctx = context(cwd);
|
|
948
|
+
const result = await handlers.get("before_agent_start")({ systemPrompt: "base prompt" }, ctx);
|
|
949
|
+
assert.equal(result.message, undefined);
|
|
950
|
+
assert.match(result.systemPrompt, /# Freeflow Interaction Contract/);
|
|
951
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Runtime Kernel/);
|
|
952
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Workflow Skill/);
|
|
953
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Decision Gate Skill/);
|
|
954
|
+
assert.doesNotMatch(result.systemPrompt, /## Discovery-light/);
|
|
955
|
+
assert.match(result.systemPrompt, /Skills: disabled/);
|
|
956
|
+
assert.match(result.systemPrompt, /Repository default mode: `workflow` \(repository\)/);
|
|
957
|
+
assert.match(result.systemPrompt, /Personal default override: `none`/);
|
|
958
|
+
assert.match(result.systemPrompt, /Configured default mode: `workflow` \(repository default\)/);
|
|
959
|
+
assert.match(result.systemPrompt, /Resolved mode: `workflow` \(inactive because Skills are disabled\)/);
|
|
960
|
+
assert.match(result.systemPrompt, /Effective Freeflow mode: `none`/);
|
|
961
|
+
assert.match(result.systemPrompt, /## Loaded Output Router Skill/);
|
|
962
|
+
assert.equal(ctx.statuses.at(-1).value, "freeflow: interaction · router");
|
|
963
|
+
assert.ok(activeToolNames().includes("freeflow_status"));
|
|
964
|
+
assert.ok(activeToolNames().includes("freeflow_search"));
|
|
965
|
+
assert.ok(activeToolNames().includes("freeflow_run"));
|
|
966
|
+
assert.ok(activeToolNames().includes("freeflow_batch"));
|
|
967
|
+
} finally {
|
|
968
|
+
await rm(cwd, { recursive: true, force: true });
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
test("Pi interaction contract can be disabled independently while Workflow stays enabled", async () => {
|
|
973
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-interaction-disabled-skills-on-"));
|
|
974
|
+
try {
|
|
975
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
976
|
+
await writeFile(
|
|
977
|
+
join(cwd, ".freeflow/config.json"),
|
|
978
|
+
JSON.stringify({ defaultMode: "workflow", interactionContract: false }, null, 2),
|
|
979
|
+
"utf8",
|
|
980
|
+
);
|
|
981
|
+
const { handlers } = loadExtension();
|
|
982
|
+
const result = await handlers.get("before_agent_start")(
|
|
983
|
+
{ prompt: "hello", systemPrompt: "base prompt" },
|
|
984
|
+
context(cwd),
|
|
985
|
+
);
|
|
986
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Interaction Contract/);
|
|
987
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Runtime Kernel/);
|
|
988
|
+
assert.equal(result.message.customType, "freeflow-workflow-bootstrap");
|
|
989
|
+
assert.match(result.message.content, /# Workflow/);
|
|
990
|
+
} finally {
|
|
991
|
+
await rm(cwd, { recursive: true, force: true });
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// Regression for the "everything disabled except Freeflow control plane" state.
|
|
996
|
+
// The model should know Freeflow exists and can be reconfigured, but should not receive workflow/router/delegation behavior.
|
|
997
|
+
// `defaultMode` is dormant while Skills are off.
|
|
998
|
+
test("Pi all-disabled capability state injects only Freeflow control-plane status", async () => {
|
|
999
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-all-capabilities-disabled-"));
|
|
1000
|
+
try {
|
|
1001
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1002
|
+
await writeFile(
|
|
1003
|
+
join(cwd, ".freeflow/config.json"),
|
|
1004
|
+
JSON.stringify(
|
|
1005
|
+
{
|
|
1006
|
+
defaultMode: "workflow",
|
|
1007
|
+
interactionContract: false,
|
|
1008
|
+
skills: { enabled: false },
|
|
1009
|
+
},
|
|
1010
|
+
null,
|
|
1011
|
+
2,
|
|
1012
|
+
),
|
|
1013
|
+
"utf8",
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
const { handlers, commands, activeToolNames } = loadExtension();
|
|
1017
|
+
const ctx = context(cwd);
|
|
1018
|
+
const result = await handlers.get("before_agent_start")({ systemPrompt: "base prompt" }, ctx);
|
|
1019
|
+
|
|
1020
|
+
assert.match(result.systemPrompt, /# Freeflow Control Plane/);
|
|
1021
|
+
assert.match(
|
|
1022
|
+
result.systemPrompt,
|
|
1023
|
+
/Freeflow is enabled for this repo, but no model-facing capabilities are enabled/,
|
|
1024
|
+
);
|
|
1025
|
+
assert.match(result.systemPrompt, /Repository default mode: `workflow` \(repository\)/);
|
|
1026
|
+
assert.match(result.systemPrompt, /Resolved mode: `workflow` \(inactive because Skills are disabled\)/);
|
|
1027
|
+
assert.match(result.systemPrompt, /Effective Freeflow mode: `none`/);
|
|
1028
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Runtime Context/);
|
|
1029
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Interaction Contract/);
|
|
1030
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Runtime Kernel/);
|
|
1031
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Mode Contract Skill/);
|
|
1032
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Workflow Skill/);
|
|
1033
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Decision Gate Skill/);
|
|
1034
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Output Router Skill/);
|
|
1035
|
+
assert.equal(ctx.statuses.at(-1).value, "freeflow: idle");
|
|
1036
|
+
assert.ok(activeToolNames().includes("freeflow_status"));
|
|
1037
|
+
assert.ok(!activeToolNames().includes("freeflow_search"));
|
|
1038
|
+
|
|
1039
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1040
|
+
assert.ok(freeflowCommand);
|
|
1041
|
+
const modeCtx = context(cwd);
|
|
1042
|
+
await freeflowCommand.definition.handler("mode strict-workflow", modeCtx);
|
|
1043
|
+
assert.match(modeCtx.notifications.at(-1).message, /Freeflow modes are inactive because Skills are disabled/);
|
|
1044
|
+
assert.match(modeCtx.notifications.at(-1).message, /Configured default mode: workflow \(repository default\)/);
|
|
1045
|
+
} finally {
|
|
1046
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
test("Pi /freeflow mode is the only mode command and opens a dedicated selector", async () => {
|
|
1051
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-mode-command-"));
|
|
1052
|
+
try {
|
|
1053
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1054
|
+
const configPath = join(cwd, ".freeflow/config.json");
|
|
1055
|
+
const configText = JSON.stringify({ defaultMode: "workflow" }, null, 2);
|
|
1056
|
+
await writeFile(configPath, configText, "utf8");
|
|
1057
|
+
|
|
1058
|
+
const { commands, entries } = loadExtension();
|
|
1059
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1060
|
+
assert.ok(freeflowCommand);
|
|
1061
|
+
assert.ok(!commands.some((command) => command.name === "workflow"));
|
|
1062
|
+
|
|
1063
|
+
const alreadyDefaultCtx = context(cwd);
|
|
1064
|
+
await freeflowCommand.definition.handler("mode workflow", alreadyDefaultCtx);
|
|
1065
|
+
assert.equal(entries.length, 0);
|
|
1066
|
+
assert.match(
|
|
1067
|
+
alreadyDefaultCtx.notifications.at(-1).message,
|
|
1068
|
+
/already in workflow mode from the configured default.*No session override was created/,
|
|
1069
|
+
);
|
|
1070
|
+
|
|
1071
|
+
const modeCtx = context(cwd);
|
|
1072
|
+
modeCtx.ui.custom = async (factory) => {
|
|
1073
|
+
let result;
|
|
1074
|
+
const component = factory({ requestRender() {} }, testTheme, {}, (value) => {
|
|
1075
|
+
result = value;
|
|
1076
|
+
});
|
|
1077
|
+
const text = renderText(component);
|
|
1078
|
+
assert.match(text, /Freeflow Mode/);
|
|
1079
|
+
assert.match(text, /Use configured default\s+workflow from repository/);
|
|
1080
|
+
assert.match(text, /conversation\s+Discussion and read-only inspection/);
|
|
1081
|
+
assert.match(text, /strict-workflow\s+Stronger decision and evidence pressure for high-risk work/);
|
|
1082
|
+
|
|
1083
|
+
component.handleInput("\u001b[B");
|
|
1084
|
+
component.handleInput("\u001b[B");
|
|
1085
|
+
component.handleInput("\u001b[B");
|
|
1086
|
+
component.handleInput("\r");
|
|
1087
|
+
return result;
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
await freeflowCommand.definition.handler("mode", modeCtx);
|
|
1091
|
+
assert.deepEqual(entries.at(-1), {
|
|
1092
|
+
customType: "freeflow-mode",
|
|
1093
|
+
data: { currentMode: "strict-workflow" },
|
|
1094
|
+
});
|
|
1095
|
+
assert.equal(modeCtx.statuses.length, 1);
|
|
1096
|
+
assert.equal(modeCtx.statuses.at(-1).value, "freeflow: interaction · strict-workflow (session)");
|
|
1097
|
+
assert.match(modeCtx.notifications.at(-1).message, /Stored in Pi session history/);
|
|
1098
|
+
assert.match(
|
|
1099
|
+
modeCtx.notifications.at(-1).message,
|
|
1100
|
+
/\.freeflow\/local\.json and \.freeflow\/config\.json were not changed/,
|
|
1101
|
+
);
|
|
1102
|
+
assert.equal(await readFile(configPath, "utf8"), configText);
|
|
1103
|
+
assert.equal(modeCtx.reloads.length, 0);
|
|
1104
|
+
|
|
1105
|
+
const alreadySessionCtx = context(cwd);
|
|
1106
|
+
await freeflowCommand.definition.handler("mode strict-workflow", alreadySessionCtx);
|
|
1107
|
+
assert.equal(entries.length, 1);
|
|
1108
|
+
assert.match(alreadySessionCtx.notifications.at(-1).message, /already in strict-workflow mode for this Pi session/);
|
|
1109
|
+
|
|
1110
|
+
const resetCtx = context(cwd);
|
|
1111
|
+
await freeflowCommand.definition.handler("mode reset", resetCtx);
|
|
1112
|
+
assert.deepEqual(entries.at(-1), {
|
|
1113
|
+
customType: "freeflow-mode",
|
|
1114
|
+
data: { currentMode: null },
|
|
1115
|
+
});
|
|
1116
|
+
assert.equal(resetCtx.statuses.at(-1).value, "freeflow: interaction · workflow");
|
|
1117
|
+
assert.match(resetCtx.notifications.at(-1).message, /reset to configured default: workflow \(repository default\)/);
|
|
1118
|
+
assert.match(resetCtx.notifications.at(-1).message, /Session override cleared/);
|
|
1119
|
+
assert.match(
|
|
1120
|
+
resetCtx.notifications.at(-1).message,
|
|
1121
|
+
/\.freeflow\/local\.json and \.freeflow\/config\.json were not changed/,
|
|
1122
|
+
);
|
|
1123
|
+
assert.equal(entries.length, 2);
|
|
1124
|
+
|
|
1125
|
+
const alreadyResetCtx = context(cwd);
|
|
1126
|
+
await freeflowCommand.definition.handler("mode reset", alreadyResetCtx);
|
|
1127
|
+
assert.equal(entries.length, 2);
|
|
1128
|
+
assert.match(alreadyResetCtx.notifications.at(-1).message, /already using the configured default: workflow/);
|
|
1129
|
+
|
|
1130
|
+
const nonTuiCtx = context(cwd);
|
|
1131
|
+
await freeflowCommand.definition.handler("mode", nonTuiCtx);
|
|
1132
|
+
assert.match(
|
|
1133
|
+
nonTuiCtx.notifications.at(-1).message,
|
|
1134
|
+
/Freeflow mode is workflow \(configured default workflow \(repository default\)\)/,
|
|
1135
|
+
);
|
|
1136
|
+
assert.doesNotMatch(nonTuiCtx.notifications.at(-1).message, /requires Pi TUI/);
|
|
1137
|
+
} finally {
|
|
1138
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
test("Pi selector commands explain the TUI requirement in RPC mode without mutation", async () => {
|
|
1143
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-rpc-selector-guidance-"));
|
|
1144
|
+
try {
|
|
1145
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1146
|
+
const configPath = join(cwd, ".freeflow/config.json");
|
|
1147
|
+
const configText = JSON.stringify({ defaultMode: "workflow" }, null, 2);
|
|
1148
|
+
await writeFile(configPath, configText, "utf8");
|
|
1149
|
+
|
|
1150
|
+
const { commands } = loadExtension();
|
|
1151
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1152
|
+
assert.ok(freeflowCommand);
|
|
1153
|
+
|
|
1154
|
+
for (const args of ["settings", "mode"]) {
|
|
1155
|
+
const ctx = context(cwd);
|
|
1156
|
+
ctx.mode = "rpc";
|
|
1157
|
+
ctx.hasUI = true;
|
|
1158
|
+
let customCalls = 0;
|
|
1159
|
+
ctx.ui.custom = async () => {
|
|
1160
|
+
customCalls += 1;
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
await freeflowCommand.definition.handler(args, ctx);
|
|
1164
|
+
|
|
1165
|
+
assert.equal(customCalls, 0);
|
|
1166
|
+
assert.match(ctx.notifications.at(-1).message, /requires? Pi TUI mode/);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
assert.equal(await readFile(configPath, "utf8"), configText);
|
|
1170
|
+
assert.equal((await readModeState(cwd)).sessionMode, null);
|
|
1171
|
+
|
|
1172
|
+
const explicitModeCtx = context(cwd);
|
|
1173
|
+
explicitModeCtx.mode = "rpc";
|
|
1174
|
+
explicitModeCtx.hasUI = true;
|
|
1175
|
+
await freeflowCommand.definition.handler("mode conversation", explicitModeCtx);
|
|
1176
|
+
assert.equal((await readModeState(cwd)).sessionMode, "conversation");
|
|
1177
|
+
} finally {
|
|
1178
|
+
restoreModeOverride(context(cwd));
|
|
1179
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("Pi selector commands fail explicitly when notifications are unavailable", async () => {
|
|
1184
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-print-selector-guidance-"));
|
|
1185
|
+
try {
|
|
1186
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1187
|
+
const configPath = join(cwd, ".freeflow/config.json");
|
|
1188
|
+
const configText = JSON.stringify({ defaultMode: "workflow" }, null, 2);
|
|
1189
|
+
await writeFile(configPath, configText, "utf8");
|
|
1190
|
+
|
|
1191
|
+
const { commands } = loadExtension();
|
|
1192
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1193
|
+
assert.ok(freeflowCommand);
|
|
1194
|
+
|
|
1195
|
+
for (const mode of ["json", "print"]) {
|
|
1196
|
+
for (const args of ["settings", "mode"]) {
|
|
1197
|
+
const ctx = context(cwd);
|
|
1198
|
+
ctx.mode = mode;
|
|
1199
|
+
ctx.hasUI = false;
|
|
1200
|
+
ctx.ui.custom = async () => undefined;
|
|
1201
|
+
await assert.rejects(freeflowCommand.definition.handler(args, ctx), /requires? Pi TUI mode/);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
assert.equal(await readFile(configPath, "utf8"), configText);
|
|
1206
|
+
assert.equal((await readModeState(cwd)).sessionMode, null);
|
|
1207
|
+
} finally {
|
|
1208
|
+
restoreModeOverride(context(cwd));
|
|
1209
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
test("Pi non-TUI selector guidance precedes inactive and invalid configuration state", async () => {
|
|
1214
|
+
const cases = [
|
|
1215
|
+
{
|
|
1216
|
+
name: "disabled",
|
|
1217
|
+
args: "mode",
|
|
1218
|
+
repository: JSON.stringify({ enabled: false, defaultMode: "workflow" }),
|
|
1219
|
+
},
|
|
1220
|
+
{
|
|
1221
|
+
name: "skills-off",
|
|
1222
|
+
args: "mode",
|
|
1223
|
+
repository: JSON.stringify({ defaultMode: "workflow", skills: { enabled: false } }),
|
|
1224
|
+
},
|
|
1225
|
+
{
|
|
1226
|
+
name: "invalid-repository",
|
|
1227
|
+
args: "settings",
|
|
1228
|
+
repository: "{ invalid\n",
|
|
1229
|
+
},
|
|
1230
|
+
{
|
|
1231
|
+
name: "invalid-local",
|
|
1232
|
+
args: "settings",
|
|
1233
|
+
repository: JSON.stringify({ defaultMode: "workflow" }),
|
|
1234
|
+
local: "{ invalid\n",
|
|
1235
|
+
},
|
|
1236
|
+
];
|
|
1237
|
+
|
|
1238
|
+
for (const [index, testCase] of cases.entries()) {
|
|
1239
|
+
const cwd = await mkdtemp(join(tmpdir(), `freeflow-pi-non-tui-${testCase.name}-`));
|
|
1240
|
+
try {
|
|
1241
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1242
|
+
const configPath = join(cwd, ".freeflow/config.json");
|
|
1243
|
+
const localPath = join(cwd, ".freeflow/local.json");
|
|
1244
|
+
await writeFile(configPath, testCase.repository, "utf8");
|
|
1245
|
+
if (testCase.local) await writeFile(localPath, testCase.local, "utf8");
|
|
1246
|
+
|
|
1247
|
+
const { commands } = loadExtension();
|
|
1248
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1249
|
+
assert.ok(freeflowCommand);
|
|
1250
|
+
|
|
1251
|
+
const rpcCtx = context(cwd);
|
|
1252
|
+
rpcCtx.mode = "rpc";
|
|
1253
|
+
rpcCtx.hasUI = true;
|
|
1254
|
+
let customCalls = 0;
|
|
1255
|
+
rpcCtx.ui.custom = async () => {
|
|
1256
|
+
customCalls += 1;
|
|
1257
|
+
};
|
|
1258
|
+
await freeflowCommand.definition.handler(testCase.args, rpcCtx);
|
|
1259
|
+
assert.equal(customCalls, 0);
|
|
1260
|
+
assert.match(rpcCtx.notifications.at(-1).message, /requires? Pi TUI mode/);
|
|
1261
|
+
|
|
1262
|
+
const nonUiCtx = context(cwd);
|
|
1263
|
+
nonUiCtx.mode = index % 2 === 0 ? "json" : "print";
|
|
1264
|
+
nonUiCtx.hasUI = false;
|
|
1265
|
+
nonUiCtx.ui.custom = async () => undefined;
|
|
1266
|
+
await assert.rejects(freeflowCommand.definition.handler(testCase.args, nonUiCtx), /requires? Pi TUI mode/);
|
|
1267
|
+
|
|
1268
|
+
assert.equal(await readFile(configPath, "utf8"), testCase.repository);
|
|
1269
|
+
if (testCase.local) assert.equal(await readFile(localPath, "utf8"), testCase.local);
|
|
1270
|
+
assert.equal((await readModeState(cwd)).sessionMode, null);
|
|
1271
|
+
} finally {
|
|
1272
|
+
restoreModeOverride(context(cwd));
|
|
1273
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
test("Pi statusline reports only effective Freeflow runtime state", async () => {
|
|
1279
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-statusline-state-"));
|
|
1280
|
+
try {
|
|
1281
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1282
|
+
const configPath = join(cwd, ".freeflow/config.json");
|
|
1283
|
+
const { pi, handlers, commands } = loadExtension();
|
|
1284
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
1285
|
+
const sessionStart = handlers.get("session_start");
|
|
1286
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1287
|
+
assert.ok(beforeAgentStart);
|
|
1288
|
+
assert.ok(sessionStart);
|
|
1289
|
+
assert.ok(freeflowCommand);
|
|
1290
|
+
|
|
1291
|
+
const statusFor = async (config) => {
|
|
1292
|
+
if (config === null) {
|
|
1293
|
+
await rm(configPath, { force: true });
|
|
1294
|
+
} else if (typeof config === "string") {
|
|
1295
|
+
await writeFile(configPath, config, "utf8");
|
|
1296
|
+
} else {
|
|
1297
|
+
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
1298
|
+
}
|
|
1299
|
+
const ctx = context(cwd);
|
|
1300
|
+
await sessionStart({ reason: "reload" }, ctx);
|
|
1301
|
+
await beforeAgentStart({ systemPrompt: "base prompt" }, ctx);
|
|
1302
|
+
return ctx.statuses.at(-1).value;
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
assert.equal(await statusFor(null), "freeflow: setup needed");
|
|
1306
|
+
assert.equal(await statusFor("{ invalid"), "freeflow: config error");
|
|
1307
|
+
assert.equal(await statusFor({ enabled: false, defaultMode: "workflow" }), "freeflow: off");
|
|
1308
|
+
assert.equal(await statusFor({ defaultMode: "workflow", skills: { enabled: false } }), "freeflow: interaction");
|
|
1309
|
+
assert.equal(
|
|
1310
|
+
await statusFor({
|
|
1311
|
+
defaultMode: "workflow",
|
|
1312
|
+
skills: { enabled: false },
|
|
1313
|
+
outputRouter: { enabled: true },
|
|
1314
|
+
}),
|
|
1315
|
+
"freeflow: interaction · router",
|
|
1316
|
+
);
|
|
1317
|
+
assert.equal(
|
|
1318
|
+
await statusFor({
|
|
1319
|
+
defaultMode: "workflow",
|
|
1320
|
+
outputRouter: { enabled: true },
|
|
1321
|
+
}),
|
|
1322
|
+
"freeflow: interaction · workflow · router",
|
|
1323
|
+
);
|
|
1324
|
+
|
|
1325
|
+
const masterOverrideCtx = context(cwd);
|
|
1326
|
+
await setSessionCoreOverride("enabled", false, masterOverrideCtx, pi);
|
|
1327
|
+
await beforeAgentStart({ systemPrompt: "base prompt" }, masterOverrideCtx);
|
|
1328
|
+
assert.equal(masterOverrideCtx.statuses.at(-1).value, "freeflow: off (session)");
|
|
1329
|
+
await resetSessionOverrides(masterOverrideCtx, pi);
|
|
1330
|
+
|
|
1331
|
+
const interactionOverrideCtx = context(cwd);
|
|
1332
|
+
await setSessionCoreOverride("interactionContract", false, interactionOverrideCtx, pi);
|
|
1333
|
+
const interactionRuntime = await beforeAgentStart({ systemPrompt: "base prompt" }, interactionOverrideCtx);
|
|
1334
|
+
assert.match(interactionRuntime.systemPrompt, /Interaction contract: disabled \(session override\)/);
|
|
1335
|
+
assert.equal(
|
|
1336
|
+
interactionOverrideCtx.statuses.at(-1).value,
|
|
1337
|
+
"freeflow: interaction off (session) · workflow · router",
|
|
1338
|
+
);
|
|
1339
|
+
await resetSessionOverrides(interactionOverrideCtx, pi);
|
|
1340
|
+
|
|
1341
|
+
const skillsOverrideCtx = context(cwd);
|
|
1342
|
+
await setSessionCoreOverride("skillsEnabled", false, skillsOverrideCtx, pi);
|
|
1343
|
+
await beforeAgentStart({ systemPrompt: "base prompt" }, skillsOverrideCtx);
|
|
1344
|
+
assert.equal(skillsOverrideCtx.statuses.at(-1).value, "freeflow: interaction · skills off (session) · router");
|
|
1345
|
+
await resetSessionOverrides(skillsOverrideCtx, pi);
|
|
1346
|
+
|
|
1347
|
+
const modeCtx = context(cwd);
|
|
1348
|
+
await freeflowCommand.definition.handler("mode conversation", modeCtx);
|
|
1349
|
+
assert.equal(modeCtx.statuses.at(-1).value, "freeflow: interaction · conversation (session) · router");
|
|
1350
|
+
await freeflowCommand.definition.handler("mode reset", context(cwd));
|
|
1351
|
+
} finally {
|
|
1352
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
test("Pi /freeflow command toggles master switch and blocks inactive settings rows", async () => {
|
|
1357
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-freeflow-settings-"));
|
|
1358
|
+
try {
|
|
1359
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1360
|
+
await writeFile(
|
|
1361
|
+
join(cwd, ".freeflow/config.json"),
|
|
1362
|
+
JSON.stringify(
|
|
1363
|
+
{
|
|
1364
|
+
enabled: false,
|
|
1365
|
+
defaultMode: "workflow",
|
|
1366
|
+
outputRouter: { enabled: true },
|
|
1367
|
+
},
|
|
1368
|
+
null,
|
|
1369
|
+
2,
|
|
1370
|
+
),
|
|
1371
|
+
"utf8",
|
|
1372
|
+
);
|
|
1373
|
+
|
|
1374
|
+
const { commands } = loadExtension();
|
|
1375
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1376
|
+
assert.ok(freeflowCommand);
|
|
1377
|
+
|
|
1378
|
+
const settingsCtx = context(cwd);
|
|
1379
|
+
settingsCtx.ui.custom = async (factory, options) => {
|
|
1380
|
+
assert.equal(options, undefined);
|
|
1381
|
+
let result;
|
|
1382
|
+
const dimTheme = {
|
|
1383
|
+
...testTheme,
|
|
1384
|
+
fg(color, text) {
|
|
1385
|
+
return color === "dim" ? `[dim]${text}[/dim]` : text;
|
|
1386
|
+
},
|
|
1387
|
+
};
|
|
1388
|
+
const component = factory({ requestRender() {} }, dimTheme, {}, (value) => {
|
|
1389
|
+
result = value;
|
|
1390
|
+
});
|
|
1391
|
+
const rootText = renderText(component);
|
|
1392
|
+
assert.match(rootText, /^─+/);
|
|
1393
|
+
assert.match(rootText, /Freeflow Settings/);
|
|
1394
|
+
assert.match(rootText, /\[dim\]Output Router\[\/dim\]/);
|
|
1395
|
+
assert.match(rootText, /enabled \(22\) \(repository\) · inactive/);
|
|
1396
|
+
assert.match(rootText, /\[dim\]Interaction Contract/);
|
|
1397
|
+
assert.match(rootText, /\[dim\]Skills/);
|
|
1398
|
+
assert.match(rootText, /\[dim\].*Output Router/);
|
|
1399
|
+
assert.doesNotMatch(rootText, /Native safety net/);
|
|
1400
|
+
component.handleInput("\u001b[B"); // Interaction Contract row is inactive while Freeflow is off.
|
|
1401
|
+
component.handleInput("\r");
|
|
1402
|
+
component.handleInput("\u001b");
|
|
1403
|
+
return result;
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
await freeflowCommand.definition.handler("settings", settingsCtx);
|
|
1407
|
+
const afterInactiveEdit = JSON.parse(await readFile(join(cwd, ".freeflow/config.json"), "utf8"));
|
|
1408
|
+
assert.deepEqual(afterInactiveEdit, {
|
|
1409
|
+
enabled: false,
|
|
1410
|
+
defaultMode: "workflow",
|
|
1411
|
+
outputRouter: { enabled: true },
|
|
1412
|
+
});
|
|
1413
|
+
assert.equal(settingsCtx.reloads.length, 0);
|
|
1414
|
+
|
|
1415
|
+
const enableCtx = context(cwd);
|
|
1416
|
+
await freeflowCommand.definition.handler("enable", enableCtx);
|
|
1417
|
+
const afterEnable = JSON.parse(await readFile(join(cwd, ".freeflow/config.json"), "utf8"));
|
|
1418
|
+
assert.equal(afterEnable.enabled, undefined);
|
|
1419
|
+
assert.equal(afterEnable.defaultMode, "workflow");
|
|
1420
|
+
assert.equal(afterEnable.outputRouter.enabled, true);
|
|
1421
|
+
assert.equal(enableCtx.reloads.length, 1);
|
|
1422
|
+
} finally {
|
|
1423
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test("Pi /freeflow disable applies live gates before reload completes", async () => {
|
|
1428
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-disable-live-gates-"));
|
|
1429
|
+
try {
|
|
1430
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1431
|
+
await writeFile(
|
|
1432
|
+
join(cwd, ".freeflow/config.json"),
|
|
1433
|
+
JSON.stringify(
|
|
1434
|
+
{
|
|
1435
|
+
defaultMode: "workflow",
|
|
1436
|
+
outputRouter: { enabled: true },
|
|
1437
|
+
},
|
|
1438
|
+
null,
|
|
1439
|
+
2,
|
|
1440
|
+
),
|
|
1441
|
+
"utf8",
|
|
1442
|
+
);
|
|
1443
|
+
|
|
1444
|
+
const { commands, activeToolNames } = loadExtension();
|
|
1445
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1446
|
+
assert.ok(freeflowCommand);
|
|
1447
|
+
assert.ok(activeToolNames().includes("freeflow_search"));
|
|
1448
|
+
|
|
1449
|
+
const ctx = context(cwd);
|
|
1450
|
+
await freeflowCommand.definition.handler("disable", ctx);
|
|
1451
|
+
|
|
1452
|
+
assert.equal(ctx.reloads.length, 1);
|
|
1453
|
+
assert.ok(!activeToolNames().includes("freeflow_status"));
|
|
1454
|
+
assert.ok(!activeToolNames().includes("freeflow_search"));
|
|
1455
|
+
assert.ok(!activeToolNames().includes("freeflow_run"));
|
|
1456
|
+
assert.ok(!activeToolNames().includes("freeflow_batch"));
|
|
1457
|
+
} finally {
|
|
1458
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
test("Pi /freeflow settings marks default mode dormant when Skills are disabled", async () => {
|
|
1463
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-settings-skills-off-mode-dormant-"));
|
|
1464
|
+
try {
|
|
1465
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1466
|
+
await writeFile(
|
|
1467
|
+
join(cwd, ".freeflow/config.json"),
|
|
1468
|
+
JSON.stringify({ defaultMode: "workflow", skills: { enabled: false } }, null, 2),
|
|
1469
|
+
"utf8",
|
|
1470
|
+
);
|
|
1471
|
+
|
|
1472
|
+
const { commands } = loadExtension();
|
|
1473
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1474
|
+
assert.ok(freeflowCommand);
|
|
1475
|
+
|
|
1476
|
+
const settingsCtx = context(cwd);
|
|
1477
|
+
settingsCtx.ui.custom = async (factory) => {
|
|
1478
|
+
let result;
|
|
1479
|
+
const component = factory({ requestRender() {} }, testTheme, {}, (value) => {
|
|
1480
|
+
result = value;
|
|
1481
|
+
});
|
|
1482
|
+
const rootText = renderText(component);
|
|
1483
|
+
assert.match(rootText, /Skills\s+disabled \(repository\)/);
|
|
1484
|
+
assert.match(rootText, /Session mode\s+default \(workflow · repository · inactive\)/);
|
|
1485
|
+
assert.match(rootText, /Default mode\s+workflow \(repository · inactive\)/);
|
|
1486
|
+
|
|
1487
|
+
component.handleInput("\u001b[B");
|
|
1488
|
+
component.handleInput("\u001b[B");
|
|
1489
|
+
component.handleInput("\u001b[B");
|
|
1490
|
+
component.handleInput("\u001b[B");
|
|
1491
|
+
component.handleInput("\r");
|
|
1492
|
+
const choiceText = renderText(component);
|
|
1493
|
+
assert.match(choiceText, /Freeflow Settings · Personal overrides › Default mode/);
|
|
1494
|
+
assert.match(choiceText, /conversation\s+Discussion and read-only inspection/);
|
|
1495
|
+
assert.match(choiceText, /workflow\s+Adaptive workflow for consequential work/);
|
|
1496
|
+
assert.match(choiceText, /strict-workflow\s+Stronger decision and evidence pressure for high-risk work/);
|
|
1497
|
+
component.handleInput("\u001b[B");
|
|
1498
|
+
component.handleInput("\u001b[B");
|
|
1499
|
+
component.handleInput("\u001b[B");
|
|
1500
|
+
component.handleInput("\r");
|
|
1501
|
+
component.handleInput("\u001b");
|
|
1502
|
+
return result;
|
|
1503
|
+
};
|
|
1504
|
+
|
|
1505
|
+
await freeflowCommand.definition.handler("settings", settingsCtx);
|
|
1506
|
+
const repositoryAfter = JSON.parse(await readFile(join(cwd, ".freeflow/config.json"), "utf8"));
|
|
1507
|
+
const localAfter = JSON.parse(await readFile(join(cwd, ".freeflow/local.json"), "utf8"));
|
|
1508
|
+
assert.equal(repositoryAfter.defaultMode, "workflow");
|
|
1509
|
+
assert.equal(repositoryAfter.skills.enabled, false);
|
|
1510
|
+
assert.equal(localAfter.defaultMode, "strict-workflow");
|
|
1511
|
+
assert.equal(settingsCtx.reloads.length, 1);
|
|
1512
|
+
} finally {
|
|
1513
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
test("Pi /freeflow settings toggles the interaction contract independently", async () => {
|
|
1518
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-settings-interaction-contract-"));
|
|
1519
|
+
try {
|
|
1520
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1521
|
+
const configPath = join(cwd, ".freeflow/config.json");
|
|
1522
|
+
await writeFile(configPath, JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
1523
|
+
await execFileAsync("git", ["init", "-q", cwd]);
|
|
1524
|
+
const { commands, handlers } = loadExtension();
|
|
1525
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1526
|
+
assert.ok(freeflowCommand);
|
|
1527
|
+
const settingsCtx = context(cwd);
|
|
1528
|
+
settingsCtx.ui.custom = async (factory) => {
|
|
1529
|
+
let result;
|
|
1530
|
+
const component = factory({ requestRender() {} }, testTheme, {}, (value) => {
|
|
1531
|
+
result = value;
|
|
1532
|
+
});
|
|
1533
|
+
assert.match(renderText(component), /Interaction Contract\s+enabled \(builtin\)/);
|
|
1534
|
+
component.handleInput("\u001b[B");
|
|
1535
|
+
component.handleInput("\r");
|
|
1536
|
+
component.handleInput("\u001b[B");
|
|
1537
|
+
component.handleInput("\u001b[B");
|
|
1538
|
+
component.handleInput("\r");
|
|
1539
|
+
component.handleInput("\u001b");
|
|
1540
|
+
return result;
|
|
1541
|
+
};
|
|
1542
|
+
await freeflowCommand.definition.handler("settings", settingsCtx);
|
|
1543
|
+
const after = JSON.parse(await readFile(configPath, "utf8"));
|
|
1544
|
+
const localAfter = JSON.parse(await readFile(join(cwd, ".freeflow/local.json"), "utf8"));
|
|
1545
|
+
assert.equal(after.interactionContract, undefined);
|
|
1546
|
+
assert.equal(after.skills, undefined);
|
|
1547
|
+
assert.equal(localAfter.interactionContract, false);
|
|
1548
|
+
await execFileAsync("git", ["-C", cwd, "check-ignore", "-q", "--", ".freeflow/local.json"]);
|
|
1549
|
+
assert.equal(settingsCtx.reloads.length, 1);
|
|
1550
|
+
const runtime = await handlers.get("before_agent_start")(
|
|
1551
|
+
{ prompt: "hello", systemPrompt: "base prompt" },
|
|
1552
|
+
context(cwd),
|
|
1553
|
+
);
|
|
1554
|
+
assert.doesNotMatch(runtime.systemPrompt, /# Freeflow Interaction Contract/);
|
|
1555
|
+
assert.equal(runtime.message.customType, "freeflow-workflow-bootstrap");
|
|
1556
|
+
const enableCtx = context(cwd);
|
|
1557
|
+
enableCtx.ui.custom = async (factory) => {
|
|
1558
|
+
let result;
|
|
1559
|
+
const component = factory({ requestRender() {} }, testTheme, {}, (value) => {
|
|
1560
|
+
result = value;
|
|
1561
|
+
});
|
|
1562
|
+
assert.match(renderText(component), /Interaction Contract\s+disabled \(local\)/);
|
|
1563
|
+
component.handleInput("\u001b[B");
|
|
1564
|
+
component.handleInput("\r");
|
|
1565
|
+
component.handleInput("\u001b[B");
|
|
1566
|
+
component.handleInput("\r");
|
|
1567
|
+
component.handleInput("\u001b");
|
|
1568
|
+
return result;
|
|
1569
|
+
};
|
|
1570
|
+
await freeflowCommand.definition.handler("settings", enableCtx);
|
|
1571
|
+
const reenabled = JSON.parse(await readFile(configPath, "utf8"));
|
|
1572
|
+
assert.equal(reenabled.interactionContract, undefined);
|
|
1573
|
+
await assert.rejects(readFile(join(cwd, ".freeflow/local.json"), "utf8"), (error) => error?.code === "ENOENT");
|
|
1574
|
+
assert.equal(enableCtx.reloads.length, 1);
|
|
1575
|
+
} finally {
|
|
1576
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1577
|
+
}
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
test("Pi session settings override Freeflow without changing config", async () => {
|
|
1581
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-session-settings-"));
|
|
1582
|
+
try {
|
|
1583
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1584
|
+
const configPath = join(cwd, ".freeflow/config.json");
|
|
1585
|
+
const configText = JSON.stringify({ defaultMode: "workflow" }, null, 2);
|
|
1586
|
+
await writeFile(configPath, configText, "utf8");
|
|
1587
|
+
|
|
1588
|
+
const { commands, entries } = loadExtension();
|
|
1589
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1590
|
+
assert.ok(freeflowCommand);
|
|
1591
|
+
const settingsCtx = context(cwd);
|
|
1592
|
+
settingsCtx.ui.custom = async (factory) => {
|
|
1593
|
+
let result;
|
|
1594
|
+
const component = factory({ requestRender() {} }, testTheme, {}, (value) => {
|
|
1595
|
+
result = value;
|
|
1596
|
+
});
|
|
1597
|
+
const rootText = renderText(component);
|
|
1598
|
+
assert.match(rootText, /Freeflow Settings · Session overrides/);
|
|
1599
|
+
assert.match(rootText, /Freeflow\s+enabled \(builtin\)/);
|
|
1600
|
+
assert.match(rootText, /Interaction Contract\s+enabled \(builtin\)/);
|
|
1601
|
+
assert.match(rootText, /Skills\s+enabled \(builtin\)/);
|
|
1602
|
+
assert.match(rootText, /Mode\s+default \(workflow · repository\)/);
|
|
1603
|
+
assert.match(rootText, /Reset session overrides\s+available/);
|
|
1604
|
+
|
|
1605
|
+
component.handleInput("\r");
|
|
1606
|
+
const choices = renderText(component);
|
|
1607
|
+
assert.match(choices, /Inherit configured value/);
|
|
1608
|
+
assert.match(choices, /Enabled for this session/);
|
|
1609
|
+
assert.match(choices, /Disabled for this session/);
|
|
1610
|
+
component.handleInput("\u001b[B");
|
|
1611
|
+
component.handleInput("\u001b[B");
|
|
1612
|
+
component.handleInput("\r");
|
|
1613
|
+
component.handleInput("\u001b");
|
|
1614
|
+
return result;
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
await freeflowCommand.definition.handler("settings session", settingsCtx);
|
|
1618
|
+
const capabilityState = await readCapabilityState(cwd);
|
|
1619
|
+
assert.equal(capabilityState.enabled, false);
|
|
1620
|
+
assert.equal(capabilityState.configSources.enabled, "session");
|
|
1621
|
+
assert.equal(settingsCtx.reloads.length, 1);
|
|
1622
|
+
assert.deepEqual(entries.at(-1), {
|
|
1623
|
+
customType: "freeflow-session-overrides",
|
|
1624
|
+
data: { overrides: { enabled: false } },
|
|
1625
|
+
});
|
|
1626
|
+
assert.equal(await readFile(configPath, "utf8"), configText);
|
|
1627
|
+
await assert.rejects(readFile(join(cwd, ".freeflow/local.json"), "utf8"), (error) => error?.code === "ENOENT");
|
|
1628
|
+
|
|
1629
|
+
const resetCtx = context(cwd);
|
|
1630
|
+
resetCtx.ui.custom = async (factory) => {
|
|
1631
|
+
let result;
|
|
1632
|
+
const component = factory({ requestRender() {} }, testTheme, {}, (value) => {
|
|
1633
|
+
result = value;
|
|
1634
|
+
});
|
|
1635
|
+
const rootText = renderText(component);
|
|
1636
|
+
assert.match(rootText, /Freeflow\s+disabled \(session\)/);
|
|
1637
|
+
assert.match(rootText, /Interaction Contract\s+enabled \(builtin\) · inactive/);
|
|
1638
|
+
for (let index = 0; index < 4; index += 1) component.handleInput("\u001b[B");
|
|
1639
|
+
component.handleInput("\r");
|
|
1640
|
+
assert.match(renderText(component), /Reset all session overrides/);
|
|
1641
|
+
component.handleInput("\r");
|
|
1642
|
+
component.handleInput("\u001b");
|
|
1643
|
+
return result;
|
|
1644
|
+
};
|
|
1645
|
+
await freeflowCommand.definition.handler("settings session", resetCtx);
|
|
1646
|
+
assert.equal((await readCapabilityState(cwd)).enabled, true);
|
|
1647
|
+
assert.equal(resetCtx.reloads.length, 1);
|
|
1648
|
+
assert.deepEqual(entries.at(-1), {
|
|
1649
|
+
customType: "freeflow-session-overrides",
|
|
1650
|
+
data: { overrides: {} },
|
|
1651
|
+
});
|
|
1652
|
+
assert.equal(await readFile(configPath, "utf8"), configText);
|
|
1653
|
+
} finally {
|
|
1654
|
+
restoreModeOverride(context(cwd));
|
|
1655
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
test("Pi settings refuse to overwrite an invalid local override", async () => {
|
|
1660
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-invalid-local-settings-"));
|
|
1661
|
+
try {
|
|
1662
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1663
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
1664
|
+
const localPath = join(cwd, ".freeflow/local.json");
|
|
1665
|
+
await writeFile(localPath, "{ invalid\n", "utf8");
|
|
1666
|
+
|
|
1667
|
+
const { commands } = loadExtension();
|
|
1668
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1669
|
+
assert.ok(freeflowCommand);
|
|
1670
|
+
const ctx = context(cwd);
|
|
1671
|
+
ctx.ui.custom = async () => {
|
|
1672
|
+
assert.fail("settings UI must not open for invalid local config");
|
|
1673
|
+
};
|
|
1674
|
+
|
|
1675
|
+
await freeflowCommand.definition.handler("settings", ctx);
|
|
1676
|
+
assert.equal(await readFile(localPath, "utf8"), "{ invalid\n");
|
|
1677
|
+
assert.match(ctx.notifications.at(-1).message, /local\.json is invalid; repair or remove it/);
|
|
1678
|
+
assert.equal(ctx.reloads.length, 0);
|
|
1679
|
+
} finally {
|
|
1680
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
test("Pi personal settings refuse to write a tracked local override", async () => {
|
|
1685
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-tracked-local-settings-"));
|
|
1686
|
+
try {
|
|
1687
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1688
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
1689
|
+
await writeFile(join(cwd, ".freeflow/local.json"), "{}\n", "utf8");
|
|
1690
|
+
await execFileAsync("git", ["init", "-q", cwd]);
|
|
1691
|
+
await execFileAsync("git", ["-C", cwd, "add", ".freeflow/local.json"]);
|
|
1692
|
+
|
|
1693
|
+
const { commands } = loadExtension();
|
|
1694
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1695
|
+
assert.ok(freeflowCommand);
|
|
1696
|
+
const ctx = context(cwd);
|
|
1697
|
+
ctx.ui.custom = async (factory) => {
|
|
1698
|
+
let result;
|
|
1699
|
+
const component = factory({ requestRender() {} }, testTheme, {}, (value) => {
|
|
1700
|
+
result = value;
|
|
1701
|
+
});
|
|
1702
|
+
component.handleInput("\u001b[B");
|
|
1703
|
+
component.handleInput("\r");
|
|
1704
|
+
component.handleInput("\u001b[B");
|
|
1705
|
+
component.handleInput("\u001b[B");
|
|
1706
|
+
component.handleInput("\r");
|
|
1707
|
+
component.handleInput("\u001b");
|
|
1708
|
+
return result;
|
|
1709
|
+
};
|
|
1710
|
+
|
|
1711
|
+
await freeflowCommand.definition.handler("settings", ctx);
|
|
1712
|
+
assert.deepEqual(JSON.parse(await readFile(join(cwd, ".freeflow/local.json"), "utf8")), {});
|
|
1713
|
+
assert.match(ctx.notifications.at(-1).message, /local\.json is tracked by git/);
|
|
1714
|
+
assert.equal(ctx.reloads.length, 0);
|
|
1715
|
+
} finally {
|
|
1716
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1717
|
+
}
|
|
1718
|
+
});
|
|
1719
|
+
|
|
1720
|
+
test("Pi settings do not report save or reload success after a mixed write failure", async () => {
|
|
1721
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-mixed-settings-write-"));
|
|
1722
|
+
try {
|
|
1723
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1724
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }), "utf8");
|
|
1725
|
+
await writeFile(join(cwd, ".freeflow/local.json"), "{}\n", "utf8");
|
|
1726
|
+
await execFileAsync("git", ["init", "-q", cwd]);
|
|
1727
|
+
await execFileAsync("git", ["-C", cwd, "add", ".freeflow/local.json"]);
|
|
1728
|
+
|
|
1729
|
+
const { commands } = loadExtension();
|
|
1730
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1731
|
+
assert.ok(freeflowCommand);
|
|
1732
|
+
const ctx = context(cwd);
|
|
1733
|
+
ctx.ui.custom = async (factory) => {
|
|
1734
|
+
let result;
|
|
1735
|
+
const component = factory({ requestRender() {} }, testTheme, {}, (value) => {
|
|
1736
|
+
result = value;
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
for (let index = 0; index < 3; index++) component.handleInput("\u001b[B");
|
|
1740
|
+
component.handleInput("\r");
|
|
1741
|
+
component.handleInput("\u001b[B");
|
|
1742
|
+
component.handleInput("\r"); // Session mode succeeds.
|
|
1743
|
+
await component.waitForWrites();
|
|
1744
|
+
|
|
1745
|
+
component.handleInput("\u001b[A");
|
|
1746
|
+
component.handleInput("\u001b[A");
|
|
1747
|
+
component.handleInput("\r");
|
|
1748
|
+
component.handleInput("\u001b[B");
|
|
1749
|
+
component.handleInput("\u001b[B");
|
|
1750
|
+
component.handleInput("\r"); // Tracked local config write fails.
|
|
1751
|
+
await component.waitForWrites();
|
|
1752
|
+
component.handleInput("\u001b");
|
|
1753
|
+
return result;
|
|
1754
|
+
};
|
|
1755
|
+
|
|
1756
|
+
await freeflowCommand.definition.handler("settings", ctx);
|
|
1757
|
+
assert.equal((await readModeState(cwd)).sessionMode, "conversation");
|
|
1758
|
+
assert.equal(ctx.reloads.length, 0);
|
|
1759
|
+
assert.match(ctx.notifications.map((notification) => notification.message).join("\n"), /Write failed:/);
|
|
1760
|
+
assert.doesNotMatch(
|
|
1761
|
+
ctx.notifications.map((notification) => notification.message).join("\n"),
|
|
1762
|
+
/personal overrides saved/,
|
|
1763
|
+
);
|
|
1764
|
+
assert.deepEqual(JSON.parse(await readFile(join(cwd, ".freeflow/local.json"), "utf8")), {});
|
|
1765
|
+
} finally {
|
|
1766
|
+
restoreModeOverride(context(cwd));
|
|
1767
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
test("Pi /freeflow settings repo edits shared defaults and shows local effective sources", async () => {
|
|
1772
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-repository-settings-"));
|
|
1773
|
+
try {
|
|
1774
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1775
|
+
const repositoryPath = join(cwd, ".freeflow/config.json");
|
|
1776
|
+
const localPath = join(cwd, ".freeflow/local.json");
|
|
1777
|
+
await writeFile(repositoryPath, JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
1778
|
+
await writeFile(
|
|
1779
|
+
localPath,
|
|
1780
|
+
JSON.stringify(
|
|
1781
|
+
{
|
|
1782
|
+
defaultMode: "conversation",
|
|
1783
|
+
processing: { unsafeUnsandboxed: { enabled: true } },
|
|
1784
|
+
},
|
|
1785
|
+
null,
|
|
1786
|
+
2,
|
|
1787
|
+
),
|
|
1788
|
+
"utf8",
|
|
1789
|
+
);
|
|
1790
|
+
|
|
1791
|
+
const { commands } = loadExtension();
|
|
1792
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1793
|
+
assert.ok(freeflowCommand);
|
|
1794
|
+
const ctx = context(cwd);
|
|
1795
|
+
ctx.ui.custom = async (factory) => {
|
|
1796
|
+
let result;
|
|
1797
|
+
const component = factory({ requestRender() {} }, testTheme, {}, (value) => {
|
|
1798
|
+
result = value;
|
|
1799
|
+
});
|
|
1800
|
+
const rootText = renderText(component);
|
|
1801
|
+
assert.match(rootText, /Freeflow Repository Settings · modifies \.freeflow\/config\.json/);
|
|
1802
|
+
assert.match(rootText, /Default mode\s+workflow \(effective conversation · local\)/);
|
|
1803
|
+
for (let index = 0; index < 4; index++) {
|
|
1804
|
+
component.handleInput("\u001b[B");
|
|
1805
|
+
}
|
|
1806
|
+
component.handleInput("\r");
|
|
1807
|
+
component.handleInput("\u001b[B");
|
|
1808
|
+
component.handleInput("\r");
|
|
1809
|
+
component.handleInput("\u001b");
|
|
1810
|
+
return result;
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1813
|
+
await freeflowCommand.definition.handler("settings repo", ctx);
|
|
1814
|
+
const repositoryAfter = JSON.parse(await readFile(repositoryPath, "utf8"));
|
|
1815
|
+
const localAfter = JSON.parse(await readFile(localPath, "utf8"));
|
|
1816
|
+
assert.equal(repositoryAfter.defaultMode, "strict-workflow");
|
|
1817
|
+
assert.equal(localAfter.defaultMode, "conversation");
|
|
1818
|
+
assert.equal(localAfter.processing.unsafeUnsandboxed.enabled, true);
|
|
1819
|
+
assert.equal(ctx.reloads.length, 1);
|
|
1820
|
+
} finally {
|
|
1821
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1822
|
+
}
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
test("Pi /freeflow settings groups capability settings", async () => {
|
|
1826
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-freeflow-grouped-settings-"));
|
|
1827
|
+
try {
|
|
1828
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1829
|
+
await writeFile(
|
|
1830
|
+
join(cwd, ".freeflow/config.json"),
|
|
1831
|
+
JSON.stringify({ defaultMode: "workflow", outputRouter: { enabled: true } }, null, 2),
|
|
1832
|
+
"utf8",
|
|
1833
|
+
);
|
|
1834
|
+
|
|
1835
|
+
const { commands } = loadExtension();
|
|
1836
|
+
const freeflowCommand = commands.find((command) => command.name === "freeflow");
|
|
1837
|
+
assert.ok(freeflowCommand);
|
|
1838
|
+
|
|
1839
|
+
const settingsCtx = context(cwd);
|
|
1840
|
+
settingsCtx.ui.custom = async (factory) => {
|
|
1841
|
+
let result;
|
|
1842
|
+
const component = factory({ requestRender() {} }, testTheme, {}, (value) => {
|
|
1843
|
+
result = value;
|
|
1844
|
+
});
|
|
1845
|
+
const rootText = renderText(component);
|
|
1846
|
+
assert.match(rootText, /^─+/);
|
|
1847
|
+
assert.match(rootText, /Output Router\s+enabled \(22\) \(repository\)/);
|
|
1848
|
+
assert.doesNotMatch(rootText, /Native safety net/);
|
|
1849
|
+
|
|
1850
|
+
for (let index = 0; index < 5; index++) {
|
|
1851
|
+
component.handleInput("\u001b[B");
|
|
1852
|
+
}
|
|
1853
|
+
component.handleInput("\r");
|
|
1854
|
+
const routerText = renderText(component);
|
|
1855
|
+
assert.match(routerText, /Freeflow Settings · Personal overrides › Output Router/);
|
|
1856
|
+
assert.match(routerText, /Native safety net/);
|
|
1857
|
+
component.handleInput("\u001b");
|
|
1858
|
+
component.handleInput("\u001b");
|
|
1859
|
+
return result;
|
|
1860
|
+
};
|
|
1861
|
+
|
|
1862
|
+
await freeflowCommand.definition.handler("settings", settingsCtx);
|
|
1863
|
+
assert.equal(settingsCtx.reloads.length, 0);
|
|
1864
|
+
} finally {
|
|
1865
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1866
|
+
}
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
test("Pi before_agent_start keeps output-router disabled by default", async () => {
|
|
1870
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-router-default-disabled-"));
|
|
1871
|
+
try {
|
|
1872
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1873
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
1874
|
+
|
|
1875
|
+
const { handlers, activeToolNames } = loadExtension();
|
|
1876
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
1877
|
+
assert.ok(beforeAgentStart);
|
|
1878
|
+
|
|
1879
|
+
const result = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
1880
|
+
|
|
1881
|
+
assert.match(result.systemPrompt, /# Freeflow Interaction Contract/);
|
|
1882
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Runtime Kernel/);
|
|
1883
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Mode Contract Skill/);
|
|
1884
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Workflow Skill/);
|
|
1885
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Decision Gate Skill/);
|
|
1886
|
+
assert.doesNotMatch(result.systemPrompt, /## Discovery-light/);
|
|
1887
|
+
assert.match(result.systemPrompt, /Output router: disabled/);
|
|
1888
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Output Router Skill/);
|
|
1889
|
+
assert.doesNotMatch(result.systemPrompt, /freeflow_search/);
|
|
1890
|
+
|
|
1891
|
+
assert.doesNotMatch(result.systemPrompt, /Legacy `FFRESULT`/);
|
|
1892
|
+
assert.doesNotMatch(result.systemPrompt, /# Context Locality/);
|
|
1893
|
+
assert.doesNotMatch(result.systemPrompt, /freeflow_run/);
|
|
1894
|
+
assert.doesNotMatch(result.systemPrompt, /freeflow_capture/);
|
|
1895
|
+
assert.doesNotMatch(result.systemPrompt, /freeflow_search action=transform/);
|
|
1896
|
+
assert.doesNotMatch(result.systemPrompt, /Native tools stay direct/);
|
|
1897
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Workflow Map/);
|
|
1898
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Output Router Safety Policy/);
|
|
1899
|
+
assert.doesNotMatch(result.systemPrompt, /Do not silently summarize or compress exactness-sensitive output/);
|
|
1900
|
+
assert.doesNotMatch(result.systemPrompt, /large native read\/bash outputs may be vaulted/);
|
|
1901
|
+
assert.doesNotMatch(result.systemPrompt, /Output-router config note/);
|
|
1902
|
+
assert.ok(!activeToolNames().includes("freeflow_search"));
|
|
1903
|
+
assert.ok(!activeToolNames().includes("freeflow_run"));
|
|
1904
|
+
assert.ok(!activeToolNames().includes("freeflow_batch"));
|
|
1905
|
+
} finally {
|
|
1906
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
test("Pi before_agent_start keeps the per-turn system context to the compact interaction contract", async () => {
|
|
1911
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-interaction-contract-"));
|
|
1912
|
+
try {
|
|
1913
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1914
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
1915
|
+
|
|
1916
|
+
const { handlers } = loadExtension();
|
|
1917
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
1918
|
+
assert.ok(beforeAgentStart);
|
|
1919
|
+
|
|
1920
|
+
const result = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
1921
|
+
|
|
1922
|
+
assert.match(result.systemPrompt, /# Freeflow Interaction Contract/);
|
|
1923
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Runtime Kernel/);
|
|
1924
|
+
assert.match(result.systemPrompt, /Runtime delivery: confirmed for this Pi `before_agent_start` invocation/);
|
|
1925
|
+
assert.doesNotMatch(result.systemPrompt, /## Conversation Mode Boundary/);
|
|
1926
|
+
assert.doesNotMatch(result.systemPrompt, /## Strict Workflow Overlay/);
|
|
1927
|
+
assert.match(result.systemPrompt, /Answer questions without inferring action/);
|
|
1928
|
+
assert.match(result.systemPrompt, /enough shared\s+understanding for\s+the next sound action/);
|
|
1929
|
+
assert.doesNotMatch(result.systemPrompt, /self-review|final assurance|standing authorization/i);
|
|
1930
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Mode Contract Skill/);
|
|
1931
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Workflow Skill/);
|
|
1932
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Decision Gate Skill/);
|
|
1933
|
+
assert.doesNotMatch(result.systemPrompt, /## Discovery-light/);
|
|
1934
|
+
} finally {
|
|
1935
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1936
|
+
}
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
test("Pi loads the full Workflow skill as one persistent first-turn message", async () => {
|
|
1940
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-workflow-bootstrap-"));
|
|
1941
|
+
try {
|
|
1942
|
+
const workflowSource = await readFile(new URL("../../../skills/workflow/SKILL.md", import.meta.url), "utf8");
|
|
1943
|
+
const workflowLines = workflowSource.split(/\r?\n/);
|
|
1944
|
+
const workflowOwnerLine = workflowLines.find((line) => line.startsWith("The active agent owns "));
|
|
1945
|
+
const workflowSelfReviewLine = workflowLines.find((line) => line.startsWith("Self-review is silent "));
|
|
1946
|
+
assert.ok(workflowOwnerLine, "canonical Workflow owner sentinel missing");
|
|
1947
|
+
assert.ok(workflowSelfReviewLine, "canonical Workflow self-review sentinel missing");
|
|
1948
|
+
|
|
1949
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
1950
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
1951
|
+
|
|
1952
|
+
const { handlers } = loadExtension();
|
|
1953
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
1954
|
+
assert.ok(beforeAgentStart);
|
|
1955
|
+
|
|
1956
|
+
const sessionEntries = [];
|
|
1957
|
+
const ctx = context(cwd, sessionEntries);
|
|
1958
|
+
const first = await beforeAgentStart({ prompt: "hi", systemPrompt: "base prompt" }, ctx);
|
|
1959
|
+
|
|
1960
|
+
assert.equal(first.message.customType, "freeflow-workflow-bootstrap");
|
|
1961
|
+
assert.equal(first.message.display, false);
|
|
1962
|
+
assert.match(first.message.content, /^# Freeflow Workflow Bootstrap/);
|
|
1963
|
+
assert.match(first.message.content, /# Workflow/);
|
|
1964
|
+
assert.ok(first.message.content.includes(workflowOwnerLine));
|
|
1965
|
+
assert.ok(first.message.content.includes(workflowSelfReviewLine));
|
|
1966
|
+
assert.match(first.message.content, /evidence/i);
|
|
1967
|
+
assert.doesNotMatch(first.systemPrompt, /# Freeflow Workflow Bootstrap/);
|
|
1968
|
+
|
|
1969
|
+
sessionEntries.push({
|
|
1970
|
+
type: "custom_message",
|
|
1971
|
+
id: "workflow-bootstrap-entry",
|
|
1972
|
+
parentId: null,
|
|
1973
|
+
timestamp: new Date().toISOString(),
|
|
1974
|
+
customType: first.message.customType,
|
|
1975
|
+
content: first.message.content,
|
|
1976
|
+
display: first.message.display,
|
|
1977
|
+
details: first.message.details,
|
|
1978
|
+
});
|
|
1979
|
+
const later = await beforeAgentStart({ prompt: "implement the feature", systemPrompt: "base prompt" }, ctx);
|
|
1980
|
+
|
|
1981
|
+
assert.equal(later.message, undefined);
|
|
1982
|
+
assert.match(later.systemPrompt, /# Freeflow Interaction Contract/);
|
|
1983
|
+
assert.doesNotMatch(later.systemPrompt, /# Freeflow Runtime Kernel/);
|
|
1984
|
+
|
|
1985
|
+
const compactedContext = context(cwd, sessionEntries, []);
|
|
1986
|
+
const afterCompaction = await beforeAgentStart(
|
|
1987
|
+
{ prompt: "continue", systemPrompt: "base prompt" },
|
|
1988
|
+
compactedContext,
|
|
1989
|
+
);
|
|
1990
|
+
assert.equal(afterCompaction.message.customType, "freeflow-workflow-bootstrap");
|
|
1991
|
+
} finally {
|
|
1992
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1993
|
+
}
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
test("Pi suppresses persisted Workflow context while Skills are disabled", async () => {
|
|
1997
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-workflow-bootstrap-disabled-"));
|
|
1998
|
+
try {
|
|
1999
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2000
|
+
const configPath = join(cwd, ".freeflow/config.json");
|
|
2001
|
+
await writeFile(
|
|
2002
|
+
configPath,
|
|
2003
|
+
JSON.stringify({ defaultMode: "workflow", skills: { enabled: false } }, null, 2),
|
|
2004
|
+
"utf8",
|
|
2005
|
+
);
|
|
2006
|
+
|
|
2007
|
+
const { handlers } = loadExtension();
|
|
2008
|
+
const contextHandler = handlers.get("context");
|
|
2009
|
+
assert.ok(contextHandler);
|
|
2010
|
+
|
|
2011
|
+
const workflowMessage = {
|
|
2012
|
+
role: "custom",
|
|
2013
|
+
customType: "freeflow-workflow-bootstrap",
|
|
2014
|
+
content: "# Freeflow Workflow Bootstrap",
|
|
2015
|
+
display: false,
|
|
2016
|
+
timestamp: Date.now(),
|
|
2017
|
+
};
|
|
2018
|
+
const userMessage = {
|
|
2019
|
+
role: "user",
|
|
2020
|
+
content: "hello",
|
|
2021
|
+
timestamp: Date.now(),
|
|
2022
|
+
};
|
|
2023
|
+
const disabled = await contextHandler({ messages: [workflowMessage, userMessage] }, context(cwd));
|
|
2024
|
+
assert.deepEqual(disabled.messages, [userMessage]);
|
|
2025
|
+
|
|
2026
|
+
await writeFile(configPath, JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
2027
|
+
const enabled = await contextHandler({ messages: [workflowMessage, userMessage] }, context(cwd));
|
|
2028
|
+
assert.equal(enabled, undefined);
|
|
2029
|
+
} finally {
|
|
2030
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2031
|
+
}
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
test("Pi before_agent_start injects the Freeflow interaction contract on every turn", async () => {
|
|
2035
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-core-context-"));
|
|
2036
|
+
try {
|
|
2037
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2038
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
2039
|
+
|
|
2040
|
+
const { handlers } = loadExtension();
|
|
2041
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
2042
|
+
assert.ok(beforeAgentStart);
|
|
2043
|
+
|
|
2044
|
+
const first = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
2045
|
+
const second = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
2046
|
+
|
|
2047
|
+
for (const result of [first, second]) {
|
|
2048
|
+
assert.match(result.systemPrompt, /# Freeflow Runtime Context/);
|
|
2049
|
+
assert.match(result.systemPrompt, /# Freeflow Interaction Contract/);
|
|
2050
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Runtime Kernel/);
|
|
2051
|
+
assert.match(result.systemPrompt, /Answer questions without inferring action/);
|
|
2052
|
+
assert.doesNotMatch(result.systemPrompt, /## Freeflow Runtime Priority/);
|
|
2053
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Mode Contract Skill/);
|
|
2054
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Workflow Skill/);
|
|
2055
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Decision Gate Skill/);
|
|
2056
|
+
assert.doesNotMatch(result.systemPrompt, /## Discovery-light/);
|
|
2057
|
+
assert.match(result.systemPrompt, /Output router: disabled/);
|
|
2058
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Output Router Skill/);
|
|
2059
|
+
assert.doesNotMatch(result.systemPrompt, /freeflow_search action=transform/);
|
|
2060
|
+
assert.doesNotMatch(result.systemPrompt, /## Freeflow Output Router Reminder/);
|
|
2061
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Workflow Map/);
|
|
2062
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Output Router Safety Policy/);
|
|
2063
|
+
}
|
|
2064
|
+
} finally {
|
|
2065
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2066
|
+
}
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
test("Pi session_start and session_compact keep the interaction contract on later turns", async () => {
|
|
2070
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-session-cache-"));
|
|
2071
|
+
try {
|
|
2072
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2073
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }, null, 2), "utf8");
|
|
2074
|
+
|
|
2075
|
+
const { handlers } = loadExtension();
|
|
2076
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
2077
|
+
const sessionStart = handlers.get("session_start");
|
|
2078
|
+
const sessionCompact = handlers.get("session_compact");
|
|
2079
|
+
assert.ok(beforeAgentStart);
|
|
2080
|
+
assert.ok(sessionStart);
|
|
2081
|
+
assert.ok(sessionCompact);
|
|
2082
|
+
|
|
2083
|
+
await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
2084
|
+
const afterFirst = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
2085
|
+
assert.match(afterFirst.systemPrompt, /# Freeflow Interaction Contract/);
|
|
2086
|
+
|
|
2087
|
+
await sessionCompact({ reason: "manual" }, context(cwd));
|
|
2088
|
+
const afterCompact = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
2089
|
+
assert.match(afterCompact.systemPrompt, /# Freeflow Interaction Contract/);
|
|
2090
|
+
assert.doesNotMatch(afterCompact.systemPrompt, /## Loaded Workflow Skill/);
|
|
2091
|
+
assert.doesNotMatch(afterCompact.systemPrompt, /## Loaded Output Router Skill/);
|
|
2092
|
+
|
|
2093
|
+
await sessionStart({ reason: "resume" }, context(cwd));
|
|
2094
|
+
const afterResume = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
2095
|
+
assert.match(afterResume.systemPrompt, /# Freeflow Interaction Contract/);
|
|
2096
|
+
assert.doesNotMatch(afterResume.systemPrompt, /## Loaded Workflow Skill/);
|
|
2097
|
+
assert.doesNotMatch(afterResume.systemPrompt, /## Loaded Output Router Skill/);
|
|
2098
|
+
} finally {
|
|
2099
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
test("Pi capability config disables output-router context, active tools, and execution", async () => {
|
|
2104
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-output-router-disabled-"));
|
|
2105
|
+
try {
|
|
2106
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2107
|
+
await writeFile(
|
|
2108
|
+
join(cwd, ".freeflow/config.json"),
|
|
2109
|
+
JSON.stringify({ defaultMode: "workflow", outputRouter: { enabled: false } }, null, 2),
|
|
2110
|
+
"utf8",
|
|
2111
|
+
);
|
|
2112
|
+
|
|
2113
|
+
const { handlers, tools, activeToolNames } = loadExtension();
|
|
2114
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
2115
|
+
assert.ok(beforeAgentStart);
|
|
2116
|
+
|
|
2117
|
+
const result = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
2118
|
+
assert.match(result.systemPrompt, /Output router: disabled/);
|
|
2119
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Output Router Skill/);
|
|
2120
|
+
assert.ok(!activeToolNames().includes("freeflow_search"));
|
|
2121
|
+
assert.ok(!activeToolNames().includes("freeflow_run"));
|
|
2122
|
+
assert.ok(!activeToolNames().includes("freeflow_batch"));
|
|
2123
|
+
|
|
2124
|
+
const searchTool = tools.find((tool) => tool.name === "freeflow_search");
|
|
2125
|
+
assert.ok(searchTool);
|
|
2126
|
+
const disabled = await searchTool.execute(
|
|
2127
|
+
"search-disabled",
|
|
2128
|
+
{ action: "locate", query: "x" },
|
|
2129
|
+
undefined,
|
|
2130
|
+
undefined,
|
|
2131
|
+
context(cwd),
|
|
2132
|
+
);
|
|
2133
|
+
assert.match(disabled.content[0].text, /freeflow_search\|disabled_by_config/);
|
|
2134
|
+
|
|
2135
|
+
const guard = handlers.get("tool_call");
|
|
2136
|
+
const blocked = await guard({ toolName: "freeflow_search" }, context(cwd));
|
|
2137
|
+
assert.equal(blocked.block, true);
|
|
2138
|
+
assert.match(blocked.reason, /disabled by Freeflow config/);
|
|
2139
|
+
} finally {
|
|
2140
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2141
|
+
}
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
test("Pi output-router command updates config and reloads", async () => {
|
|
2145
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-capability-command-"));
|
|
2146
|
+
try {
|
|
2147
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2148
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({}, null, 2), "utf8");
|
|
2149
|
+
|
|
2150
|
+
const { commands } = loadExtension();
|
|
2151
|
+
const outputRouterCommand = commands.find((command) => command.name === "output-router");
|
|
2152
|
+
assert.ok(outputRouterCommand);
|
|
2153
|
+
|
|
2154
|
+
const outputCtx = context(cwd);
|
|
2155
|
+
await outputRouterCommand.definition.handler("enable", outputCtx);
|
|
2156
|
+
const afterOutput = JSON.parse(await readFile(join(cwd, ".freeflow/config.json"), "utf8"));
|
|
2157
|
+
assert.equal(afterOutput.outputRouter.enabled, true);
|
|
2158
|
+
assert.equal(outputCtx.reloads.length, 1);
|
|
2159
|
+
} finally {
|
|
2160
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2161
|
+
}
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2164
|
+
test("Pi output-router settings UI toggles multiple config values and reloads once", async () => {
|
|
2165
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-output-router-settings-"));
|
|
2166
|
+
try {
|
|
2167
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2168
|
+
await writeFile(
|
|
2169
|
+
join(cwd, ".freeflow/config.json"),
|
|
2170
|
+
JSON.stringify(
|
|
2171
|
+
{
|
|
2172
|
+
defaultMode: "workflow",
|
|
2173
|
+
outputRouter: { generatedPaths: ["graphify-out/**"] },
|
|
2174
|
+
},
|
|
2175
|
+
null,
|
|
2176
|
+
2,
|
|
2177
|
+
),
|
|
2178
|
+
"utf8",
|
|
2179
|
+
);
|
|
2180
|
+
|
|
2181
|
+
const { commands } = loadExtension();
|
|
2182
|
+
const outputRouterCommand = commands.find((command) => command.name === "output-router");
|
|
2183
|
+
assert.ok(outputRouterCommand);
|
|
2184
|
+
|
|
2185
|
+
const ctx = context(cwd);
|
|
2186
|
+
ctx.ui.custom = async (factory) => {
|
|
2187
|
+
let result;
|
|
2188
|
+
const component = factory({ requestRender() {} }, testTheme, {}, (value) => {
|
|
2189
|
+
result = value;
|
|
2190
|
+
});
|
|
2191
|
+
assert.match(renderText(component), /Output Router Settings/);
|
|
2192
|
+
component.handleInput("\r");
|
|
2193
|
+
component.handleInput("\u001b[A");
|
|
2194
|
+
component.handleInput("\r"); // Output Router enabled
|
|
2195
|
+
await component.waitForWrites();
|
|
2196
|
+
for (let index = 0; index < 9; index++) {
|
|
2197
|
+
component.handleInput("\u001b[B");
|
|
2198
|
+
}
|
|
2199
|
+
component.handleInput("\r");
|
|
2200
|
+
component.handleInput("\u001b[A");
|
|
2201
|
+
component.handleInput("\r"); // Script transform enabled
|
|
2202
|
+
await component.waitForWrites();
|
|
2203
|
+
component.handleInput("\u001b");
|
|
2204
|
+
return result;
|
|
2205
|
+
};
|
|
2206
|
+
|
|
2207
|
+
await outputRouterCommand.definition.handler("", ctx);
|
|
2208
|
+
|
|
2209
|
+
const after = JSON.parse(await readFile(join(cwd, ".freeflow/config.json"), "utf8"));
|
|
2210
|
+
assert.equal(after.outputRouter.enabled, true);
|
|
2211
|
+
assert.deepEqual(after.outputRouter.hints.generatedPathGlobs, ["graphify-out/**"]);
|
|
2212
|
+
assert.equal(after.outputRouter.generatedPaths, undefined);
|
|
2213
|
+
assert.equal(after.outputRouter.scriptTransform.enabled, true);
|
|
2214
|
+
assert.equal(ctx.reloads.length, 1);
|
|
2215
|
+
} finally {
|
|
2216
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2217
|
+
}
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
test("Pi output-router status summarizes master and subfeature state", async () => {
|
|
2221
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-output-router-status-"));
|
|
2222
|
+
try {
|
|
2223
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2224
|
+
await writeFile(
|
|
2225
|
+
join(cwd, ".freeflow/config.json"),
|
|
2226
|
+
JSON.stringify(
|
|
2227
|
+
{
|
|
2228
|
+
defaultMode: "workflow",
|
|
2229
|
+
outputRouter: {
|
|
2230
|
+
enabled: true,
|
|
2231
|
+
postToolRouting: "safety-net",
|
|
2232
|
+
scriptTransform: { enabled: true },
|
|
2233
|
+
observedRouting: { enabled: true },
|
|
2234
|
+
},
|
|
2235
|
+
},
|
|
2236
|
+
null,
|
|
2237
|
+
2,
|
|
2238
|
+
),
|
|
2239
|
+
"utf8",
|
|
2240
|
+
);
|
|
2241
|
+
|
|
2242
|
+
const { commands } = loadExtension();
|
|
2243
|
+
const outputRouterCommand = commands.find((command) => command.name === "output-router");
|
|
2244
|
+
assert.ok(outputRouterCommand);
|
|
2245
|
+
|
|
2246
|
+
const ctx = context(cwd);
|
|
2247
|
+
await outputRouterCommand.definition.handler("status", ctx);
|
|
2248
|
+
|
|
2249
|
+
assert.equal(ctx.notifications.length, 1);
|
|
2250
|
+
assert.match(ctx.notifications[0].message, /Output Router: enabled/);
|
|
2251
|
+
assert.match(ctx.notifications[0].message, /script transform: enabled/);
|
|
2252
|
+
assert.match(ctx.notifications[0].message, /observed routing: enabled/);
|
|
2253
|
+
assert.match(ctx.notifications[0].message, /native safety net: safety-net/);
|
|
2254
|
+
} finally {
|
|
2255
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2256
|
+
}
|
|
2257
|
+
});
|
|
2258
|
+
|
|
2259
|
+
test("Pi freeflow_status reports effective defaults without writing config", async () => {
|
|
2260
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-status-minimal-"));
|
|
2261
|
+
try {
|
|
2262
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2263
|
+
const configPath = join(cwd, ".freeflow/config.json");
|
|
2264
|
+
const configText = JSON.stringify({ defaultMode: "workflow" }, null, 2);
|
|
2265
|
+
await writeFile(configPath, configText, "utf8");
|
|
2266
|
+
|
|
2267
|
+
const { tools } = loadExtension();
|
|
2268
|
+
const statusTool = tools.find((tool) => tool.name === "freeflow_status");
|
|
2269
|
+
assert.ok(statusTool);
|
|
2270
|
+
|
|
2271
|
+
const result = await statusTool.execute("status-minimal", { action: "doctor" }, undefined, undefined, context(cwd));
|
|
2272
|
+
const report = JSON.parse(result.content[0].text);
|
|
2273
|
+
|
|
2274
|
+
assert.equal(report.toolStatus, "ok");
|
|
2275
|
+
assert.equal(report.action, "doctor");
|
|
2276
|
+
assert.equal(report.mode.defaultMode, "workflow");
|
|
2277
|
+
assert.deepEqual(report.effectiveConfig.interactionContract, {
|
|
2278
|
+
enabled: true,
|
|
2279
|
+
effective: true,
|
|
2280
|
+
});
|
|
2281
|
+
assert.equal(report.effectiveDefaults.interactionContract, true);
|
|
2282
|
+
assert.equal(report.effectiveConfig.outputRouter.enabled, false);
|
|
2283
|
+
assert.equal(report.effectiveConfig.outputRouter.profile, "standard");
|
|
2284
|
+
assert.equal(report.effectiveConfig.outputRouter.postToolRouting, "off");
|
|
2285
|
+
assert.equal("capture" in report.effectiveConfig, false);
|
|
2286
|
+
assert.equal("providers" in report.effectiveConfig, false);
|
|
2287
|
+
assert.equal(report.effectiveConfig.outputRouter.observedRouting.enabled, false);
|
|
2288
|
+
assert.deepEqual(report.effectiveConfig.outputRouter.observedRouting.mcp.servers, {});
|
|
2289
|
+
assert.equal(report.effectiveConfig.outputRouter.scriptTransform.enabled, false);
|
|
2290
|
+
assert.equal(report.effectiveLocalConfig.processing.unsafeUnsandboxed.enabled, false);
|
|
2291
|
+
assert.equal(report.processing.unsafeUnsandboxed.enabled, false);
|
|
2292
|
+
assert.equal(report.processing.unsafeUnsandboxed.status, "disabled");
|
|
2293
|
+
assert.equal(report.localConfigExists, false);
|
|
2294
|
+
assert.deepEqual(report.localConfigWarnings, []);
|
|
2295
|
+
assert.equal(report.scriptTransform.enabled, false);
|
|
2296
|
+
assert.equal(report.scriptTransform.executionStatus, "disabled");
|
|
2297
|
+
assert.ok(["available", "unavailable"].includes(report.scriptTransform.adapterStatus));
|
|
2298
|
+
assert.equal(report.scriptTransform.adapterContractVersion, 1);
|
|
2299
|
+
assert.deepEqual(report.scriptTransform.configuredLanguages, ["javascript", "python", "jq"]);
|
|
2300
|
+
const reportedLanguages = [
|
|
2301
|
+
...report.scriptTransform.availableLanguages,
|
|
2302
|
+
...report.scriptTransform.unavailableLanguages.map((entry) => entry.language),
|
|
2303
|
+
].sort();
|
|
2304
|
+
assert.deepEqual(reportedLanguages, ["javascript", "jq", "python"]);
|
|
2305
|
+
assert.ok(report.scriptTransform.requiredProofs.includes("network_access_denied"));
|
|
2306
|
+
assert.ok(
|
|
2307
|
+
report.scriptTransform.candidateMechanisms.some(
|
|
2308
|
+
(candidate) => candidate.id === "node-vm" && candidate.status === "rejected",
|
|
2309
|
+
),
|
|
2310
|
+
);
|
|
2311
|
+
assert.ok(
|
|
2312
|
+
report.scriptTransform.candidateMechanisms.some(
|
|
2313
|
+
(candidate) => candidate.id === "os-sandbox-adapter" && candidate.status === "candidate_unproven",
|
|
2314
|
+
),
|
|
2315
|
+
);
|
|
2316
|
+
assert.equal(report.scriptTransform.network, "off");
|
|
2317
|
+
assert.equal(report.scriptTransform.rawScriptPersistence, "disabled");
|
|
2318
|
+
assert.equal(report.observedRouting.host.name, "pi");
|
|
2319
|
+
assert.equal(report.observedRouting.host.outputReplacement, "available");
|
|
2320
|
+
assert.equal(report.vaultIndex.engine, "local-json-sidecar");
|
|
2321
|
+
assert.equal(typeof report.vaultIndex.available, "boolean");
|
|
2322
|
+
assert.equal(report.vaultIndex.degraded, false);
|
|
2323
|
+
assert.equal(report.vaultIndex.stale, false);
|
|
2324
|
+
assert.equal(report.vaultIndex.rebuildRecommended, false);
|
|
2325
|
+
assert.equal(typeof report.vaultIndex.entryCount, "number");
|
|
2326
|
+
assert.ok(report.vaultIndex.entryCount >= 0);
|
|
2327
|
+
assert.equal(report.observedRouting.unsupportedPersistenceModes.includes("redacted"), true);
|
|
2328
|
+
assert.deepEqual(report.configWarnings, []);
|
|
2329
|
+
assert.match(report.vault.root, /freeflow-router\/vault$/);
|
|
2330
|
+
assert.ok(
|
|
2331
|
+
[
|
|
2332
|
+
"writable",
|
|
2333
|
+
"missing_ancestor_writable",
|
|
2334
|
+
"missing_ancestor_unavailable",
|
|
2335
|
+
"not_directory",
|
|
2336
|
+
"not_writable",
|
|
2337
|
+
"unknown",
|
|
2338
|
+
].includes(report.vault.writability.status),
|
|
2339
|
+
);
|
|
2340
|
+
assert.equal(await readFile(configPath, "utf8"), configText);
|
|
2341
|
+
} finally {
|
|
2342
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
|
|
2346
|
+
test("Pi freeflow_status reports layered core sources and dormant mode", async () => {
|
|
2347
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-status-layered-core-"));
|
|
2348
|
+
try {
|
|
2349
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2350
|
+
await writeFile(
|
|
2351
|
+
join(cwd, ".freeflow/config.json"),
|
|
2352
|
+
JSON.stringify({
|
|
2353
|
+
enabled: true,
|
|
2354
|
+
interactionContract: true,
|
|
2355
|
+
defaultMode: "workflow",
|
|
2356
|
+
skills: { enabled: true },
|
|
2357
|
+
}),
|
|
2358
|
+
"utf8",
|
|
2359
|
+
);
|
|
2360
|
+
await writeFile(
|
|
2361
|
+
join(cwd, ".freeflow/local.json"),
|
|
2362
|
+
JSON.stringify({
|
|
2363
|
+
interactionContract: false,
|
|
2364
|
+
defaultMode: "strict-workflow",
|
|
2365
|
+
skills: { enabled: false },
|
|
2366
|
+
}),
|
|
2367
|
+
"utf8",
|
|
2368
|
+
);
|
|
2369
|
+
|
|
2370
|
+
const { tools } = loadExtension();
|
|
2371
|
+
const statusTool = tools.find((tool) => tool.name === "freeflow_status");
|
|
2372
|
+
assert.ok(statusTool);
|
|
2373
|
+
const result = await statusTool.execute(
|
|
2374
|
+
"status-layered-core",
|
|
2375
|
+
{ action: "status" },
|
|
2376
|
+
undefined,
|
|
2377
|
+
undefined,
|
|
2378
|
+
context(cwd),
|
|
2379
|
+
);
|
|
2380
|
+
const report = JSON.parse(result.content[0].text);
|
|
2381
|
+
|
|
2382
|
+
assert.equal(report.configValid, true);
|
|
2383
|
+
assert.equal(report.localConfigValid, true);
|
|
2384
|
+
assert.deepEqual(report.configuration.sources, {
|
|
2385
|
+
enabled: "repository",
|
|
2386
|
+
interactionContract: "local",
|
|
2387
|
+
skillsEnabled: "local",
|
|
2388
|
+
defaultMode: "local",
|
|
2389
|
+
});
|
|
2390
|
+
assert.equal(report.effectiveConfig.enabled, true);
|
|
2391
|
+
assert.equal(report.effectiveConfig.defaultMode, "strict-workflow");
|
|
2392
|
+
assert.deepEqual(report.effectiveConfig.interactionContract, {
|
|
2393
|
+
enabled: false,
|
|
2394
|
+
effective: false,
|
|
2395
|
+
});
|
|
2396
|
+
assert.deepEqual(report.effectiveConfig.skills, {
|
|
2397
|
+
enabled: false,
|
|
2398
|
+
effective: false,
|
|
2399
|
+
});
|
|
2400
|
+
assert.equal(report.mode.repositoryDefaultMode, "workflow");
|
|
2401
|
+
assert.equal(report.mode.repositoryDefaultModeSource, "repository");
|
|
2402
|
+
assert.equal(report.mode.personalDefaultMode, "strict-workflow");
|
|
2403
|
+
assert.equal(report.mode.defaultMode, "strict-workflow");
|
|
2404
|
+
assert.equal(report.mode.defaultModeSource, "local");
|
|
2405
|
+
assert.equal(report.mode.sessionMode, null);
|
|
2406
|
+
assert.equal(report.mode.active, false);
|
|
2407
|
+
assert.equal(report.mode.resolvedMode, "strict-workflow");
|
|
2408
|
+
assert.equal(report.mode.effectiveMode, null);
|
|
2409
|
+
|
|
2410
|
+
const expanded = renderText(statusTool.renderResult(result, { expanded: true }, testTheme));
|
|
2411
|
+
assert.match(expanded, /repository:\s+valid/);
|
|
2412
|
+
assert.match(expanded, /personal:\s+valid/);
|
|
2413
|
+
assert.match(expanded, /interactionContract:\s+false\s+\(local\)/);
|
|
2414
|
+
assert.match(expanded, /skills:\s+false\s+\(local\)/);
|
|
2415
|
+
assert.match(expanded, /active:\s+false/);
|
|
2416
|
+
assert.match(expanded, /effective:\s+none/);
|
|
2417
|
+
assert.match(expanded, /resolved:\s+strict-workflow/);
|
|
2418
|
+
assert.match(expanded, /repository default:\s+workflow\s+\(repository\)/);
|
|
2419
|
+
assert.match(expanded, /personal default override:\s+strict-workflow/);
|
|
2420
|
+
assert.match(expanded, /configured default:\s+strict-workflow\s+\(local\)/);
|
|
2421
|
+
} finally {
|
|
2422
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2423
|
+
}
|
|
2424
|
+
});
|
|
2425
|
+
|
|
2426
|
+
test("Pi freeflow_status fails closed and explains invalid local core config", async () => {
|
|
2427
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-status-invalid-local-"));
|
|
2428
|
+
try {
|
|
2429
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2430
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }), "utf8");
|
|
2431
|
+
await writeFile(join(cwd, ".freeflow/local.json"), JSON.stringify({ skills: { enabled: "no" } }), "utf8");
|
|
2432
|
+
|
|
2433
|
+
const { tools } = loadExtension();
|
|
2434
|
+
const statusTool = tools.find((tool) => tool.name === "freeflow_status");
|
|
2435
|
+
assert.ok(statusTool);
|
|
2436
|
+
const result = await statusTool.execute(
|
|
2437
|
+
"status-invalid-local",
|
|
2438
|
+
{ action: "doctor" },
|
|
2439
|
+
undefined,
|
|
2440
|
+
undefined,
|
|
2441
|
+
context(cwd),
|
|
2442
|
+
);
|
|
2443
|
+
const report = JSON.parse(result.content[0].text);
|
|
2444
|
+
|
|
2445
|
+
assert.equal(report.configValid, false);
|
|
2446
|
+
assert.equal(report.repositoryConfigValid, true);
|
|
2447
|
+
assert.equal(report.localConfigValid, false);
|
|
2448
|
+
assert.equal(report.effectiveConfig.configured, false);
|
|
2449
|
+
assert.equal(report.effectiveConfig.enabled, false);
|
|
2450
|
+
assert.equal(report.mode.active, false);
|
|
2451
|
+
assert.match(report.configuration.personal.error, /skills\.enabled/);
|
|
2452
|
+
assert.ok(
|
|
2453
|
+
report.localConfigWarnings.some(
|
|
2454
|
+
(warning) => warning.includes(".freeflow/local.json is invalid") && warning.includes("runtime is inactive"),
|
|
2455
|
+
),
|
|
2456
|
+
);
|
|
2457
|
+
|
|
2458
|
+
const collapsed = renderText(statusTool.renderResult(result, { expanded: false }, testTheme));
|
|
2459
|
+
assert.match(collapsed, /freeflow off/);
|
|
2460
|
+
assert.match(collapsed, /mode dormant \(workflow\)/);
|
|
2461
|
+
assert.match(collapsed, /warnings=1/);
|
|
2462
|
+
} finally {
|
|
2463
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2464
|
+
}
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
test("Pi freeflow_status reports vault writability without creating directories", async () => {
|
|
2468
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-status-vault-"));
|
|
2469
|
+
try {
|
|
2470
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2471
|
+
const nestedVault = join(cwd, "missing", "nested", "vault");
|
|
2472
|
+
await writeFile(
|
|
2473
|
+
join(cwd, ".freeflow/config.json"),
|
|
2474
|
+
JSON.stringify({
|
|
2475
|
+
defaultMode: "workflow",
|
|
2476
|
+
outputRouter: { vaultRoot: nestedVault },
|
|
2477
|
+
}),
|
|
2478
|
+
"utf8",
|
|
2479
|
+
);
|
|
2480
|
+
|
|
2481
|
+
const { tools } = loadExtension();
|
|
2482
|
+
const statusTool = tools.find((tool) => tool.name === "freeflow_status");
|
|
2483
|
+
const missingResult = await statusTool.execute(
|
|
2484
|
+
"status-vault-missing",
|
|
2485
|
+
{ action: "doctor" },
|
|
2486
|
+
undefined,
|
|
2487
|
+
undefined,
|
|
2488
|
+
context(cwd),
|
|
2489
|
+
);
|
|
2490
|
+
const missingReport = JSON.parse(missingResult.content[0].text);
|
|
2491
|
+
|
|
2492
|
+
assert.equal(missingReport.vault.writability.status, "missing_ancestor_writable");
|
|
2493
|
+
await assert.rejects(readFile(nestedVault, "utf8"));
|
|
2494
|
+
|
|
2495
|
+
const fileVault = join(cwd, "vault-file");
|
|
2496
|
+
await writeFile(fileVault, "not a directory", "utf8");
|
|
2497
|
+
await writeFile(
|
|
2498
|
+
join(cwd, ".freeflow/config.json"),
|
|
2499
|
+
JSON.stringify({
|
|
2500
|
+
defaultMode: "workflow",
|
|
2501
|
+
outputRouter: { enabled: true, vaultRoot: fileVault },
|
|
2502
|
+
}),
|
|
2503
|
+
"utf8",
|
|
2504
|
+
);
|
|
2505
|
+
const fileResult = await statusTool.execute(
|
|
2506
|
+
"status-vault-file",
|
|
2507
|
+
{ action: "doctor" },
|
|
2508
|
+
undefined,
|
|
2509
|
+
undefined,
|
|
2510
|
+
context(cwd),
|
|
2511
|
+
);
|
|
2512
|
+
const fileReport = JSON.parse(fileResult.content[0].text);
|
|
2513
|
+
assert.equal(fileReport.vault.writability.status, "not_directory");
|
|
2514
|
+
} finally {
|
|
2515
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2516
|
+
}
|
|
2517
|
+
});
|
|
2518
|
+
|
|
2519
|
+
test("Pi freeflow_status reports local unsafe processing opt-in without shared config", async () => {
|
|
2520
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-status-local-processing-"));
|
|
2521
|
+
try {
|
|
2522
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2523
|
+
await writeFile(join(cwd, ".freeflow/config.json"), JSON.stringify({ defaultMode: "workflow" }), "utf8");
|
|
2524
|
+
await writeFile(
|
|
2525
|
+
join(cwd, ".freeflow/local.json"),
|
|
2526
|
+
JSON.stringify({ processing: { unsafeUnsandboxed: { enabled: true } } }),
|
|
2527
|
+
"utf8",
|
|
2528
|
+
);
|
|
2529
|
+
|
|
2530
|
+
const { tools } = loadExtension();
|
|
2531
|
+
const statusTool = tools.find((tool) => tool.name === "freeflow_status");
|
|
2532
|
+
const result = await statusTool.execute(
|
|
2533
|
+
"status-local-processing",
|
|
2534
|
+
{ action: "doctor" },
|
|
2535
|
+
undefined,
|
|
2536
|
+
undefined,
|
|
2537
|
+
context(cwd),
|
|
2538
|
+
);
|
|
2539
|
+
const report = JSON.parse(result.content[0].text);
|
|
2540
|
+
|
|
2541
|
+
assert.equal(report.localConfigExists, true);
|
|
2542
|
+
assert.equal(report.effectiveLocalConfig.processing.unsafeUnsandboxed.enabled, true);
|
|
2543
|
+
assert.equal(report.processing.unsafeUnsandboxed.enabled, true);
|
|
2544
|
+
assert.equal(report.processing.unsafeUnsandboxed.status, "enabled_unsafe");
|
|
2545
|
+
assert.deepEqual(report.configWarnings, []);
|
|
2546
|
+
assert.deepEqual(report.localConfigWarnings, []);
|
|
2547
|
+
} finally {
|
|
2548
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2549
|
+
}
|
|
2550
|
+
});
|
|
2551
|
+
|
|
2552
|
+
test("Pi freeflow_status warns that shared config cannot enable unsafe processing", async () => {
|
|
2553
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-status-shared-processing-"));
|
|
2554
|
+
try {
|
|
2555
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2556
|
+
await writeFile(
|
|
2557
|
+
join(cwd, ".freeflow/config.json"),
|
|
2558
|
+
JSON.stringify({
|
|
2559
|
+
defaultMode: "workflow",
|
|
2560
|
+
processing: { unsafeUnsandboxed: { enabled: true } },
|
|
2561
|
+
}),
|
|
2562
|
+
"utf8",
|
|
2563
|
+
);
|
|
2564
|
+
|
|
2565
|
+
const { tools } = loadExtension();
|
|
2566
|
+
const statusTool = tools.find((tool) => tool.name === "freeflow_status");
|
|
2567
|
+
const result = await statusTool.execute(
|
|
2568
|
+
"status-shared-processing",
|
|
2569
|
+
{ action: "doctor" },
|
|
2570
|
+
undefined,
|
|
2571
|
+
undefined,
|
|
2572
|
+
context(cwd),
|
|
2573
|
+
);
|
|
2574
|
+
const report = JSON.parse(result.content[0].text);
|
|
2575
|
+
|
|
2576
|
+
assert.equal(report.effectiveLocalConfig.processing.unsafeUnsandboxed.enabled, false);
|
|
2577
|
+
assert.equal(report.processing.unsafeUnsandboxed.enabled, false);
|
|
2578
|
+
assert.ok(report.configWarnings.some((warning) => warning.includes("processing config is ignored")));
|
|
2579
|
+
} finally {
|
|
2580
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2581
|
+
}
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
test("Pi freeflow_status reports configured observed routing", async () => {
|
|
2585
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-status-observed-"));
|
|
2586
|
+
try {
|
|
2587
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2588
|
+
await writeFile(
|
|
2589
|
+
join(cwd, ".freeflow/config.json"),
|
|
2590
|
+
JSON.stringify({
|
|
2591
|
+
defaultMode: "workflow",
|
|
2592
|
+
outputRouter: {
|
|
2593
|
+
enabled: true,
|
|
2594
|
+
observedRouting: {
|
|
2595
|
+
enabled: true,
|
|
2596
|
+
onRoutingFailure: "fail-open",
|
|
2597
|
+
mcp: {
|
|
2598
|
+
servers: {
|
|
2599
|
+
github: { enabled: true, persistence: "exact" },
|
|
2600
|
+
gmail: { enabled: true, persistence: "metadata-only" },
|
|
2601
|
+
},
|
|
2602
|
+
},
|
|
2603
|
+
web: { enabled: true, persistence: "exact" },
|
|
2604
|
+
fetch: { enabled: false },
|
|
2605
|
+
codeSearch: { enabled: true, persistence: "none" },
|
|
2606
|
+
},
|
|
2607
|
+
scriptTransform: {
|
|
2608
|
+
enabled: true,
|
|
2609
|
+
languages: ["python"],
|
|
2610
|
+
limits: {
|
|
2611
|
+
timeoutMs: 1000,
|
|
2612
|
+
maxInputBytes: 2048,
|
|
2613
|
+
maxOutputBytes: 4096,
|
|
2614
|
+
},
|
|
2615
|
+
},
|
|
2616
|
+
},
|
|
2617
|
+
}),
|
|
2618
|
+
"utf8",
|
|
2619
|
+
);
|
|
2620
|
+
|
|
2621
|
+
const { tools } = loadExtension();
|
|
2622
|
+
const statusTool = tools.find((tool) => tool.name === "freeflow_status");
|
|
2623
|
+
const result = await statusTool.execute(
|
|
2624
|
+
"status-observed",
|
|
2625
|
+
{ action: "doctor" },
|
|
2626
|
+
undefined,
|
|
2627
|
+
undefined,
|
|
2628
|
+
context(cwd),
|
|
2629
|
+
);
|
|
2630
|
+
const report = JSON.parse(result.content[0].text);
|
|
2631
|
+
|
|
2632
|
+
assert.equal(report.effectiveConfig.outputRouter.observedRouting.enabled, true);
|
|
2633
|
+
assert.equal(report.effectiveConfig.outputRouter.observedRouting.onRoutingFailure, "fail-open");
|
|
2634
|
+
assert.deepEqual(report.effectiveConfig.outputRouter.observedRouting.mcp.servers.github, {
|
|
2635
|
+
enabled: true,
|
|
2636
|
+
persistence: "exact",
|
|
2637
|
+
});
|
|
2638
|
+
assert.deepEqual(report.effectiveConfig.outputRouter.observedRouting.mcp.servers.gmail, {
|
|
2639
|
+
enabled: true,
|
|
2640
|
+
persistence: "metadata-only",
|
|
2641
|
+
});
|
|
2642
|
+
assert.deepEqual(report.effectiveConfig.outputRouter.observedRouting.web, {
|
|
2643
|
+
enabled: true,
|
|
2644
|
+
persistence: "exact",
|
|
2645
|
+
});
|
|
2646
|
+
assert.deepEqual(report.effectiveConfig.outputRouter.observedRouting.fetch, {
|
|
2647
|
+
enabled: false,
|
|
2648
|
+
persistence: "none",
|
|
2649
|
+
});
|
|
2650
|
+
assert.deepEqual(report.effectiveConfig.outputRouter.observedRouting.codeSearch, {
|
|
2651
|
+
enabled: true,
|
|
2652
|
+
persistence: "none",
|
|
2653
|
+
});
|
|
2654
|
+
assert.equal(report.observedRouting.enabled, true);
|
|
2655
|
+
assert.equal(report.observedRouting.mcp.configuredServerCount, 2);
|
|
2656
|
+
assert.equal(report.effectiveConfig.outputRouter.scriptTransform.enabled, true);
|
|
2657
|
+
assert.deepEqual(report.effectiveConfig.outputRouter.scriptTransform.languages, ["python"]);
|
|
2658
|
+
assert.equal(report.scriptTransform.enabled, true);
|
|
2659
|
+
assert.ok(["available", "adapter_unavailable"].includes(report.scriptTransform.executionStatus));
|
|
2660
|
+
assert.equal(typeof report.scriptTransform.adapterAvailable, "boolean");
|
|
2661
|
+
if (report.scriptTransform.adapterAvailable) {
|
|
2662
|
+
assert.ok(report.scriptTransform.availableLanguages.includes("python"));
|
|
2663
|
+
}
|
|
2664
|
+
assert.deepEqual(report.observedRouting.persistenceModes, ["exact", "metadata-only", "none"]);
|
|
2665
|
+
assert.deepEqual(report.configWarnings, []);
|
|
2666
|
+
} finally {
|
|
2667
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2668
|
+
}
|
|
2669
|
+
});
|
|
2670
|
+
|
|
2671
|
+
test("Pi freeflow_status reports invalid config warnings and safe fallbacks", async () => {
|
|
2672
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-status-invalid-"));
|
|
2673
|
+
try {
|
|
2674
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2675
|
+
await writeFile(
|
|
2676
|
+
join(cwd, ".freeflow/config.json"),
|
|
2677
|
+
JSON.stringify({
|
|
2678
|
+
defaultMode: "workflow",
|
|
2679
|
+
outputRouter: {
|
|
2680
|
+
enabled: "yes",
|
|
2681
|
+
profile: "future",
|
|
2682
|
+
scriptTransform: {
|
|
2683
|
+
enabled: "yes",
|
|
2684
|
+
sandbox: "none",
|
|
2685
|
+
languages: ["ruby"],
|
|
2686
|
+
network: "on",
|
|
2687
|
+
},
|
|
2688
|
+
observedRouting: {
|
|
2689
|
+
enabled: "yes",
|
|
2690
|
+
mcp: {
|
|
2691
|
+
servers: { github: { enabled: true, persistence: "redacted" } },
|
|
2692
|
+
},
|
|
2693
|
+
web: { enabled: true },
|
|
2694
|
+
},
|
|
2695
|
+
},
|
|
2696
|
+
capture: { freeflowMediated: "metadata-only", directHostTools: "raw" },
|
|
2697
|
+
providers: { enabled: [{ id: "serena", mode: "write" }] },
|
|
2698
|
+
}),
|
|
2699
|
+
"utf8",
|
|
2700
|
+
);
|
|
2701
|
+
|
|
2702
|
+
const { tools } = loadExtension();
|
|
2703
|
+
const statusTool = tools.find((tool) => tool.name === "freeflow_status");
|
|
2704
|
+
const result = await statusTool.execute("status-invalid", { action: "doctor" }, undefined, undefined, context(cwd));
|
|
2705
|
+
const report = JSON.parse(result.content[0].text);
|
|
2706
|
+
|
|
2707
|
+
assert.equal(report.effectiveConfig.outputRouter.enabled, false);
|
|
2708
|
+
assert.equal(report.effectiveConfig.outputRouter.profile, "standard");
|
|
2709
|
+
assert.equal("capture" in report.effectiveConfig, false);
|
|
2710
|
+
assert.equal("providers" in report.effectiveConfig, false);
|
|
2711
|
+
assert.equal(report.effectiveConfig.outputRouter.observedRouting.enabled, false);
|
|
2712
|
+
assert.equal(report.effectiveConfig.outputRouter.scriptTransform.enabled, false);
|
|
2713
|
+
assert.deepEqual(report.effectiveConfig.outputRouter.scriptTransform.languages, ["javascript", "python", "jq"]);
|
|
2714
|
+
assert.equal(report.effectiveConfig.outputRouter.observedRouting.mcp.servers.github.persistence, "metadata-only");
|
|
2715
|
+
assert.equal(report.effectiveConfig.outputRouter.observedRouting.web.persistence, "metadata-only");
|
|
2716
|
+
assert.ok(report.configWarnings.some((warning) => warning.includes("outputRouter.enabled")));
|
|
2717
|
+
assert.ok(report.configWarnings.some((warning) => warning.includes("outputRouter.profile")));
|
|
2718
|
+
assert.ok(report.configWarnings.some((warning) => warning.includes("outputRouter.scriptTransform.enabled")));
|
|
2719
|
+
assert.ok(report.configWarnings.some((warning) => warning.includes("outputRouter.scriptTransform.sandbox")));
|
|
2720
|
+
assert.ok(report.configWarnings.some((warning) => warning.includes("outputRouter.scriptTransform.languages")));
|
|
2721
|
+
assert.ok(report.configWarnings.some((warning) => warning.includes("outputRouter.scriptTransform.network")));
|
|
2722
|
+
assert.ok(report.configWarnings.some((warning) => warning.includes("outputRouter.observedRouting.enabled")));
|
|
2723
|
+
assert.ok(
|
|
2724
|
+
report.configWarnings.some(
|
|
2725
|
+
(warning) =>
|
|
2726
|
+
warning.includes("outputRouter.observedRouting.mcp.servers.github.persistence") &&
|
|
2727
|
+
warning.includes("redacted"),
|
|
2728
|
+
),
|
|
2729
|
+
);
|
|
2730
|
+
assert.ok(
|
|
2731
|
+
report.configWarnings.some((warning) => warning.includes("outputRouter.observedRouting.web.persistence")),
|
|
2732
|
+
);
|
|
2733
|
+
} finally {
|
|
2734
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2735
|
+
}
|
|
2736
|
+
});
|
|
2737
|
+
|
|
2738
|
+
test("Pi freeflow_status migration recommendations are non-destructive", async () => {
|
|
2739
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-status-migration-"));
|
|
2740
|
+
try {
|
|
2741
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2742
|
+
const configPath = join(cwd, ".freeflow/config.json");
|
|
2743
|
+
const configText = JSON.stringify(
|
|
2744
|
+
{
|
|
2745
|
+
defaultMode: "workflow",
|
|
2746
|
+
outputRouter: {
|
|
2747
|
+
enabled: true,
|
|
2748
|
+
profile: "standard",
|
|
2749
|
+
postToolRouting: "off",
|
|
2750
|
+
largeOutputBytes: 64000,
|
|
2751
|
+
largeOutputLines: 1000,
|
|
2752
|
+
vaultRoot: "~/.cache/freeflow-router/vault",
|
|
2753
|
+
vaultRetentionDays: 7,
|
|
2754
|
+
},
|
|
2755
|
+
capture: { freeflowMediated: "raw", directHostTools: "off" },
|
|
2756
|
+
providers: { enabled: [] },
|
|
2757
|
+
},
|
|
2758
|
+
null,
|
|
2759
|
+
2,
|
|
2760
|
+
);
|
|
2761
|
+
await writeFile(configPath, configText, "utf8");
|
|
2762
|
+
|
|
2763
|
+
const { tools } = loadExtension();
|
|
2764
|
+
const statusTool = tools.find((tool) => tool.name === "freeflow_status");
|
|
2765
|
+
const result = await statusTool.execute(
|
|
2766
|
+
"status-migration",
|
|
2767
|
+
{ action: "migration" },
|
|
2768
|
+
undefined,
|
|
2769
|
+
undefined,
|
|
2770
|
+
context(cwd),
|
|
2771
|
+
);
|
|
2772
|
+
const report = JSON.parse(result.content[0].text);
|
|
2773
|
+
|
|
2774
|
+
assert.equal(report.action, "migration");
|
|
2775
|
+
assert.equal(report.migration.applied, false);
|
|
2776
|
+
assert.equal(report.migration.requiresConfirmation, true);
|
|
2777
|
+
assert.ok(
|
|
2778
|
+
report.migration.recommendations.some((recommendation) => recommendation.path === "outputRouter.postToolRouting"),
|
|
2779
|
+
);
|
|
2780
|
+
assert.ok(report.migration.recommendations.some((recommendation) => recommendation.path === "capture"));
|
|
2781
|
+
assert.ok(report.migration.recommendations.some((recommendation) => recommendation.path === "providers"));
|
|
2782
|
+
assert.equal(await readFile(configPath, "utf8"), configText);
|
|
2783
|
+
} finally {
|
|
2784
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2785
|
+
}
|
|
2786
|
+
});
|
|
2787
|
+
|
|
2788
|
+
test("Pi outputRouter.enabled=false suppresses router context and native safety net", async () => {
|
|
2789
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-router-disabled-"));
|
|
2790
|
+
try {
|
|
2791
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2792
|
+
await writeFile(
|
|
2793
|
+
join(cwd, ".freeflow/config.json"),
|
|
2794
|
+
JSON.stringify({
|
|
2795
|
+
defaultMode: "workflow",
|
|
2796
|
+
outputRouter: {
|
|
2797
|
+
enabled: false,
|
|
2798
|
+
postToolRouting: "safety-net",
|
|
2799
|
+
largeOutputLines: 1,
|
|
2800
|
+
largeOutputBytes: 1,
|
|
2801
|
+
},
|
|
2802
|
+
}),
|
|
2803
|
+
"utf8",
|
|
2804
|
+
);
|
|
2805
|
+
|
|
2806
|
+
const { handlers } = loadExtension();
|
|
2807
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
2808
|
+
const result = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
2809
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Output Router Skill/);
|
|
2810
|
+
|
|
2811
|
+
const toolResult = await handlers.get("tool_result")(
|
|
2812
|
+
{
|
|
2813
|
+
type: "tool_result",
|
|
2814
|
+
toolName: "read",
|
|
2815
|
+
toolCallId: "read-router-disabled",
|
|
2816
|
+
input: { path: "large.txt" },
|
|
2817
|
+
content: [{ type: "text", text: "line 1\nline 2" }],
|
|
2818
|
+
details: undefined,
|
|
2819
|
+
isError: false,
|
|
2820
|
+
},
|
|
2821
|
+
context(cwd),
|
|
2822
|
+
);
|
|
2823
|
+
assert.equal(toolResult, undefined);
|
|
2824
|
+
} finally {
|
|
2825
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2826
|
+
}
|
|
2827
|
+
});
|
|
2828
|
+
|
|
2829
|
+
test("Pi output-router context mentions native safety net only when config enables it", async () => {
|
|
2830
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-config-"));
|
|
2831
|
+
try {
|
|
2832
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
2833
|
+
await writeFile(
|
|
2834
|
+
join(cwd, ".freeflow/config.json"),
|
|
2835
|
+
JSON.stringify({
|
|
2836
|
+
defaultMode: "workflow",
|
|
2837
|
+
outputRouter: { enabled: true, postToolRouting: "safety-net" },
|
|
2838
|
+
}),
|
|
2839
|
+
"utf8",
|
|
2840
|
+
);
|
|
2841
|
+
|
|
2842
|
+
const { handlers } = loadExtension();
|
|
2843
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
2844
|
+
const result = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
2845
|
+
|
|
2846
|
+
assert.match(result.systemPrompt, /large native read\/bash outputs may be vaulted/);
|
|
2847
|
+
assert.match(result.systemPrompt, /## Loaded Output Router Skill/);
|
|
2848
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Output Router Safety Policy/);
|
|
2849
|
+
assert.match(result.systemPrompt, /freeflow_search/);
|
|
2850
|
+
assert.match(result.systemPrompt, /freeflow_search action=transform/);
|
|
2851
|
+
} finally {
|
|
2852
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2853
|
+
}
|
|
2854
|
+
});
|
|
2855
|
+
|
|
2856
|
+
test("Pi freeflow_search renders compact and expanded routed evidence UI", () => {
|
|
2857
|
+
const { tools } = loadExtension();
|
|
2858
|
+
const searchTool = tools.find((tool) => tool.name === "freeflow_search");
|
|
2859
|
+
assert.ok(searchTool);
|
|
2860
|
+
assert.ok(searchTool.parameters.properties.action.enum.includes("get"));
|
|
2861
|
+
|
|
2862
|
+
const call = renderText(
|
|
2863
|
+
searchTool.renderCall(
|
|
2864
|
+
{
|
|
2865
|
+
action: "query",
|
|
2866
|
+
source: {
|
|
2867
|
+
kind: "repo",
|
|
2868
|
+
path: "docs/codex-cli-agent-harness/passes/2026-06-12-pass-3-sandboxing-and-permissions.md",
|
|
2869
|
+
},
|
|
2870
|
+
query: "SandboxPermissions Plain-language meaning",
|
|
2871
|
+
},
|
|
2872
|
+
testTheme,
|
|
2873
|
+
),
|
|
2874
|
+
200,
|
|
2875
|
+
);
|
|
2876
|
+
assert.match(call, /freeflow_search query repo/);
|
|
2877
|
+
assert.match(call, /SandboxPermissions/);
|
|
2878
|
+
|
|
2879
|
+
const toolResult = {
|
|
2880
|
+
content: [{ type: "text", text: "raw json should not be the visible UI" }],
|
|
2881
|
+
details: {
|
|
2882
|
+
result: {
|
|
2883
|
+
toolStatus: "ok",
|
|
2884
|
+
decisionId: "ffdec_test",
|
|
2885
|
+
preserve: "important",
|
|
2886
|
+
source: { kind: "repo", path: "docs/example.md" },
|
|
2887
|
+
routing: {
|
|
2888
|
+
status: "routed",
|
|
2889
|
+
route: "search",
|
|
2890
|
+
reason: "Deterministic test route.",
|
|
2891
|
+
},
|
|
2892
|
+
evidence: [
|
|
2893
|
+
{
|
|
2894
|
+
id: "ev_test",
|
|
2895
|
+
source: { kind: "repo", path: "docs/example.md" },
|
|
2896
|
+
path: "docs/example.md",
|
|
2897
|
+
lines: "523-527",
|
|
2898
|
+
excerpt: "### Sandbox Permissions\n\n`SandboxPermissions` is a per-command request shape.",
|
|
2899
|
+
why: "Matched exact heading and identifier.",
|
|
2900
|
+
window: "small",
|
|
2901
|
+
expandable: true,
|
|
2902
|
+
},
|
|
2903
|
+
],
|
|
2904
|
+
recovery: {
|
|
2905
|
+
how: "Use freeflow_search action=expand with evidenceId=ev_test.",
|
|
2906
|
+
evidenceId: "ev_test",
|
|
2907
|
+
},
|
|
2908
|
+
},
|
|
2909
|
+
},
|
|
2910
|
+
};
|
|
2911
|
+
|
|
2912
|
+
const collapsed = renderText(searchTool.renderResult(toolResult, { expanded: false }, testTheme));
|
|
2913
|
+
assert.match(collapsed, /1 evidence packet/);
|
|
2914
|
+
assert.match(collapsed, /docs\/example\.md:523-527/);
|
|
2915
|
+
assert.match(collapsed, /ctrl\+o to expand/);
|
|
2916
|
+
assert.doesNotMatch(collapsed, /raw json/);
|
|
2917
|
+
|
|
2918
|
+
const expanded = renderText(searchTool.renderResult(toolResult, { expanded: true }, testTheme));
|
|
2919
|
+
assert.match(expanded, /Source/);
|
|
2920
|
+
assert.match(expanded, /preserve: important/);
|
|
2921
|
+
assert.match(expanded, /Storage/);
|
|
2922
|
+
assert.match(expanded, /decisionId: ffdec_test/);
|
|
2923
|
+
assert.match(expanded, /Evidence/);
|
|
2924
|
+
assert.match(expanded, /evidenceId: ev_test/);
|
|
2925
|
+
assert.match(expanded, /source: repo docs\/example\.md/);
|
|
2926
|
+
assert.match(expanded, /expandable: true/);
|
|
2927
|
+
assert.match(expanded, /exact search: action=retrieve source.kind=repo lineRange=523-527 path=docs\/example\.md/);
|
|
2928
|
+
assert.match(expanded, /### Sandbox Permissions/);
|
|
2929
|
+
assert.match(expanded, /Recovery/);
|
|
2930
|
+
assert.match(expanded, /expand hint: freeflow_search action=expand evidenceId=ev_test/);
|
|
2931
|
+
});
|
|
2932
|
+
|
|
2933
|
+
test("Pi freeflow_run exposes declarative filter schema", () => {
|
|
2934
|
+
const { tools } = loadExtension();
|
|
2935
|
+
const runTool = tools.find((tool) => tool.name === "freeflow_run");
|
|
2936
|
+
assert.ok(runTool);
|
|
2937
|
+
|
|
2938
|
+
assert.deepEqual(runTool.parameters.oneOf, [{ required: ["command"] }, { required: ["script"] }]);
|
|
2939
|
+
|
|
2940
|
+
const script = runTool.parameters.properties.script;
|
|
2941
|
+
assert.equal(script.type, "object");
|
|
2942
|
+
assert.equal(script.additionalProperties, false);
|
|
2943
|
+
assert.deepEqual(script.properties.language.enum, ["javascript", "python", "jq"]);
|
|
2944
|
+
assert.equal(script.properties.code.minLength, 1);
|
|
2945
|
+
assert.equal(script.properties.limits.properties.timeoutMs.maximum, 30000);
|
|
2946
|
+
assert.deepEqual(script.required, ["language", "code"]);
|
|
2947
|
+
|
|
2948
|
+
const filters = runTool.parameters.properties.filters;
|
|
2949
|
+
assert.equal(filters.type, "object");
|
|
2950
|
+
assert.equal(filters.additionalProperties, false);
|
|
2951
|
+
assert.deepEqual(filters.properties.stream.enum, ["stdout", "stderr", "combined"]);
|
|
2952
|
+
assert.equal(filters.properties.include.items.minLength, 1);
|
|
2953
|
+
assert.equal(filters.properties.exclude.items.minLength, 1);
|
|
2954
|
+
assert.match(filters.properties.flags.pattern, /gimsu/);
|
|
2955
|
+
assert.equal(filters.properties.head.minimum, 1);
|
|
2956
|
+
assert.equal(filters.properties.tail.minimum, 1);
|
|
2957
|
+
assert.equal(filters.properties.maxLines.minimum, 1);
|
|
2958
|
+
assert.equal(filters.properties.maxBytes.minimum, 1);
|
|
2959
|
+
|
|
2960
|
+
const scriptFilter = runTool.parameters.properties.scriptFilter;
|
|
2961
|
+
assert.equal(scriptFilter.type, "object");
|
|
2962
|
+
assert.equal(scriptFilter.additionalProperties, false);
|
|
2963
|
+
assert.deepEqual(scriptFilter.properties.language.enum, ["javascript", "python", "jq"]);
|
|
2964
|
+
assert.equal(scriptFilter.properties.code.minLength, 1);
|
|
2965
|
+
assert.equal(scriptFilter.properties.limits.properties.timeoutMs.maximum, 30000);
|
|
2966
|
+
assert.deepEqual(scriptFilter.required, ["language", "code"]);
|
|
2967
|
+
});
|
|
2968
|
+
|
|
2969
|
+
test("Pi freeflow_run renders compact and expanded status, evidence, and vault UI", () => {
|
|
2970
|
+
const { tools } = loadExtension();
|
|
2971
|
+
const runTool = tools.find((tool) => tool.name === "freeflow_run");
|
|
2972
|
+
assert.ok(runTool);
|
|
2973
|
+
|
|
2974
|
+
const call = renderText(runTool.renderCall({ command: "npm test -- --runInBand", preserve: "important" }, testTheme));
|
|
2975
|
+
assert.match(call, /freeflow_run \$ npm test/);
|
|
2976
|
+
assert.match(call, /preserve=important/);
|
|
2977
|
+
|
|
2978
|
+
const toolResult = {
|
|
2979
|
+
content: [{ type: "text", text: "raw json should not be the visible UI" }],
|
|
2980
|
+
details: {
|
|
2981
|
+
result: {
|
|
2982
|
+
toolStatus: "ok",
|
|
2983
|
+
decisionId: "ffdec_run_test",
|
|
2984
|
+
preserve: "important",
|
|
2985
|
+
outputId: "ffout_test123",
|
|
2986
|
+
recordId: "ffrec_test123",
|
|
2987
|
+
execution: { status: "failed", exitCode: 1, durationMs: 842 },
|
|
2988
|
+
routing: {
|
|
2989
|
+
status: "routed",
|
|
2990
|
+
route: "run",
|
|
2991
|
+
reason: "Command failed; exact failure evidence was returned and raw output was vaulted before routing.",
|
|
2992
|
+
},
|
|
2993
|
+
summary: "Command failed with exitCode=1.",
|
|
2994
|
+
parser: {
|
|
2995
|
+
name: "test-runner",
|
|
2996
|
+
confidence: 0.92,
|
|
2997
|
+
fidelity: "exact",
|
|
2998
|
+
compressed: true,
|
|
2999
|
+
counts: { testsFailed: 1 },
|
|
3000
|
+
},
|
|
3001
|
+
persistence: {
|
|
3002
|
+
status: "vaulted",
|
|
3003
|
+
recoverability: "exact",
|
|
3004
|
+
recoveryOutputId: "ffout_test123",
|
|
3005
|
+
},
|
|
3006
|
+
filters: {
|
|
3007
|
+
stream: "stderr",
|
|
3008
|
+
include: ["AssertionError"],
|
|
3009
|
+
sourceLines: 2,
|
|
3010
|
+
selectedLines: 1,
|
|
3011
|
+
},
|
|
3012
|
+
scriptFilter: {
|
|
3013
|
+
status: "success",
|
|
3014
|
+
language: "javascript",
|
|
3015
|
+
label: "failures-only",
|
|
3016
|
+
rawOutputId: "ffout_test123",
|
|
3017
|
+
sourceAliases: ["stdout", "stderr", "combined"],
|
|
3018
|
+
outputId: "ffout_script123",
|
|
3019
|
+
operation: {
|
|
3020
|
+
kind: "script",
|
|
3021
|
+
language: "javascript",
|
|
3022
|
+
codeSha256: "sha256_abc",
|
|
3023
|
+
},
|
|
3024
|
+
},
|
|
3025
|
+
importantLines: [
|
|
3026
|
+
{
|
|
3027
|
+
stream: "stderr",
|
|
3028
|
+
lines: "14-16",
|
|
3029
|
+
excerpt: "AssertionError: expected false to equal true\nSTACK_BENCH_MARKER exact failure line",
|
|
3030
|
+
},
|
|
3031
|
+
],
|
|
3032
|
+
recovery: {
|
|
3033
|
+
how: "Use freeflow_search with source.kind=vault and outputId=ffout_test123 to recover exact command output.",
|
|
3034
|
+
outputId: "ffout_test123",
|
|
3035
|
+
},
|
|
3036
|
+
},
|
|
3037
|
+
},
|
|
3038
|
+
};
|
|
3039
|
+
|
|
3040
|
+
const collapsed = renderText(runTool.renderResult(toolResult, { expanded: false }, testTheme));
|
|
3041
|
+
assert.match(collapsed, /execution: failed/);
|
|
3042
|
+
assert.match(collapsed, /routing: routed/);
|
|
3043
|
+
assert.match(collapsed, /ffout_test123/);
|
|
3044
|
+
assert.match(collapsed, /ffout_script123/);
|
|
3045
|
+
assert.match(collapsed, /parser test-runner 0\.92/);
|
|
3046
|
+
assert.match(collapsed, /raw and script output recoverable from vault/);
|
|
3047
|
+
assert.match(collapsed, /ctrl\+o to expand/);
|
|
3048
|
+
assert.doesNotMatch(collapsed, /raw json/);
|
|
3049
|
+
|
|
3050
|
+
const expanded = renderText(
|
|
3051
|
+
runTool.renderResult(toolResult, { expanded: true }, testTheme, {
|
|
3052
|
+
args: { command: "npm test -- --runInBand" },
|
|
3053
|
+
}),
|
|
3054
|
+
);
|
|
3055
|
+
assert.match(expanded, /Status/);
|
|
3056
|
+
assert.match(expanded, /execution.status: failed/);
|
|
3057
|
+
assert.match(expanded, /Storage/);
|
|
3058
|
+
assert.match(expanded, /decisionId: ffdec_run_test/);
|
|
3059
|
+
assert.match(expanded, /recordId: ffrec_test123/);
|
|
3060
|
+
assert.match(expanded, /persistence: vaulted \/ exact/);
|
|
3061
|
+
assert.match(expanded, /Filters/);
|
|
3062
|
+
assert.match(expanded, /stream=stderr/);
|
|
3063
|
+
assert.match(expanded, /include=AssertionError/);
|
|
3064
|
+
assert.match(expanded, /selected=1\/2/);
|
|
3065
|
+
assert.match(expanded, /Script filter/);
|
|
3066
|
+
assert.match(expanded, /javascript:success/);
|
|
3067
|
+
assert.match(expanded, /rawOutputId: ffout_test123/);
|
|
3068
|
+
assert.match(expanded, /sources: stdout, stderr, combined/);
|
|
3069
|
+
assert.match(expanded, /sha256_abc/);
|
|
3070
|
+
assert.match(expanded, /Parser/);
|
|
3071
|
+
assert.match(expanded, /confidence: 0\.92/);
|
|
3072
|
+
assert.match(expanded, /counts:.*testsFailed/);
|
|
3073
|
+
assert.match(expanded, /Evidence/);
|
|
3074
|
+
assert.match(expanded, /AssertionError/);
|
|
3075
|
+
assert.match(expanded, /Vault recovery/);
|
|
3076
|
+
assert.match(expanded, /source.kind=vault/);
|
|
3077
|
+
assert.match(
|
|
3078
|
+
expanded,
|
|
3079
|
+
/exact search: action=retrieve source.kind=vault lineRange=14-16 stream=raw outputId=ffout_script123/,
|
|
3080
|
+
);
|
|
3081
|
+
assert.match(expanded, /raw command starting point: freeflow_search source.kind=vault outputId=ffout_test123/);
|
|
3082
|
+
assert.match(expanded, /details\.result/);
|
|
3083
|
+
});
|
|
3084
|
+
|
|
3085
|
+
test("Pi freeflow_run returns compact model-visible text with full structured details", async () => {
|
|
3086
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-run-compact-text-"));
|
|
3087
|
+
try {
|
|
3088
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3089
|
+
await writeFile(
|
|
3090
|
+
join(cwd, ".freeflow/config.json"),
|
|
3091
|
+
JSON.stringify({
|
|
3092
|
+
defaultMode: "workflow",
|
|
3093
|
+
outputRouter: { enabled: true },
|
|
3094
|
+
}),
|
|
3095
|
+
"utf8",
|
|
3096
|
+
);
|
|
3097
|
+
const tools = [];
|
|
3098
|
+
const pi = {
|
|
3099
|
+
registerTool(tool) {
|
|
3100
|
+
tools.push(tool);
|
|
3101
|
+
},
|
|
3102
|
+
registerCommand() {},
|
|
3103
|
+
on() {},
|
|
3104
|
+
appendEntry() {},
|
|
3105
|
+
sendUserMessage() {},
|
|
3106
|
+
async exec() {
|
|
3107
|
+
return {
|
|
3108
|
+
stdout: "Tests: 1 failed, 24 passed, 25 total\n",
|
|
3109
|
+
stderr: "AssertionError: expected false to equal true\nSTACK_BENCH_MARKER exact failure line\n",
|
|
3110
|
+
code: 1,
|
|
3111
|
+
killed: false,
|
|
3112
|
+
};
|
|
3113
|
+
},
|
|
3114
|
+
};
|
|
3115
|
+
freeflowExtension(pi);
|
|
3116
|
+
const runTool = tools.find((tool) => tool.name === "freeflow_run");
|
|
3117
|
+
assert.ok(runTool);
|
|
3118
|
+
|
|
3119
|
+
const result = await runTool.execute(
|
|
3120
|
+
"tool-call",
|
|
3121
|
+
{ command: "npm test", goal: "verification" },
|
|
3122
|
+
undefined,
|
|
3123
|
+
undefined,
|
|
3124
|
+
context(cwd),
|
|
3125
|
+
);
|
|
3126
|
+
|
|
3127
|
+
const visibleText = result.content[0].text;
|
|
3128
|
+
const detailsText = JSON.stringify(result.details.result, null, 2);
|
|
3129
|
+
|
|
3130
|
+
assert.match(visibleText, /freeflow_run\|failed/);
|
|
3131
|
+
assert.match(visibleText, /exit=1/);
|
|
3132
|
+
assert.match(visibleText, /raw=ffout_/);
|
|
3133
|
+
assert.match(visibleText, /STACK_BENCH_MARKER exact failure line/);
|
|
3134
|
+
assert.match(visibleText, /rec\|vault\|ffout_[^|]+\|stderr\|1-3/);
|
|
3135
|
+
assert.match(visibleText, /details\.result/);
|
|
3136
|
+
assert.doesNotMatch(visibleText, /^\s*\{/);
|
|
3137
|
+
assert.ok(Buffer.byteLength(visibleText, "utf8") < Buffer.byteLength(detailsText, "utf8"));
|
|
3138
|
+
assert.ok(Buffer.byteLength(visibleText, "utf8") < 900);
|
|
3139
|
+
|
|
3140
|
+
assert.equal(result.details.result.toolStatus, "ok");
|
|
3141
|
+
assert.equal(result.details.result.execution.status, "failed");
|
|
3142
|
+
assert.equal(result.details.result.execution.exitCode, 1);
|
|
3143
|
+
assert.ok(result.details.result.recovery.outputId.startsWith("ffout_"));
|
|
3144
|
+
assert.ok(Array.isArray(result.details.result.importantLines));
|
|
3145
|
+
} finally {
|
|
3146
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3147
|
+
}
|
|
3148
|
+
});
|
|
3149
|
+
|
|
3150
|
+
test("Pi freeflow_run forwards script producer and does not call host exec when sandbox is disabled", async () => {
|
|
3151
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-run-script-producer-"));
|
|
3152
|
+
try {
|
|
3153
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3154
|
+
await writeFile(
|
|
3155
|
+
join(cwd, ".freeflow/config.json"),
|
|
3156
|
+
JSON.stringify({
|
|
3157
|
+
defaultMode: "workflow",
|
|
3158
|
+
outputRouter: { enabled: true },
|
|
3159
|
+
}),
|
|
3160
|
+
"utf8",
|
|
3161
|
+
);
|
|
3162
|
+
let execCalls = 0;
|
|
3163
|
+
const tools = [];
|
|
3164
|
+
const pi = {
|
|
3165
|
+
registerTool(tool) {
|
|
3166
|
+
tools.push(tool);
|
|
3167
|
+
},
|
|
3168
|
+
registerCommand() {},
|
|
3169
|
+
on() {},
|
|
3170
|
+
appendEntry() {},
|
|
3171
|
+
sendUserMessage() {},
|
|
3172
|
+
async exec() {
|
|
3173
|
+
execCalls += 1;
|
|
3174
|
+
throw new Error("host exec should not be called for sandboxed script producers");
|
|
3175
|
+
},
|
|
3176
|
+
};
|
|
3177
|
+
freeflowExtension(pi);
|
|
3178
|
+
const runTool = tools.find((tool) => tool.name === "freeflow_run");
|
|
3179
|
+
assert.ok(runTool);
|
|
3180
|
+
|
|
3181
|
+
const result = await runTool.execute(
|
|
3182
|
+
"tool-call-script-producer",
|
|
3183
|
+
{
|
|
3184
|
+
script: {
|
|
3185
|
+
language: "javascript",
|
|
3186
|
+
code: "RAW_PI_SCRIPT",
|
|
3187
|
+
label: "pi-script",
|
|
3188
|
+
},
|
|
3189
|
+
},
|
|
3190
|
+
undefined,
|
|
3191
|
+
undefined,
|
|
3192
|
+
context(cwd),
|
|
3193
|
+
);
|
|
3194
|
+
|
|
3195
|
+
assert.equal(execCalls, 0);
|
|
3196
|
+
assert.match(result.content[0].text, /freeflow_run\|failed/);
|
|
3197
|
+
assert.equal(result.details.result.toolStatus, "error");
|
|
3198
|
+
assert.equal(result.details.result.producer.kind, "script");
|
|
3199
|
+
assert.equal(result.details.result.producer.name, "pi-script");
|
|
3200
|
+
assert.equal(result.details.result.failure.kind, "script_transform_disabled");
|
|
3201
|
+
assert.equal(result.details.result.scriptProducer.status, "unavailable");
|
|
3202
|
+
assert.doesNotMatch(JSON.stringify(result), /RAW_PI_SCRIPT/);
|
|
3203
|
+
} finally {
|
|
3204
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3205
|
+
}
|
|
3206
|
+
});
|
|
3207
|
+
|
|
3208
|
+
test("Pi freeflow_batch returns compact summary and preserves child details", async () => {
|
|
3209
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-batch-compact-text-"));
|
|
3210
|
+
try {
|
|
3211
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3212
|
+
await writeFile(
|
|
3213
|
+
join(cwd, ".freeflow/config.json"),
|
|
3214
|
+
JSON.stringify({
|
|
3215
|
+
defaultMode: "workflow",
|
|
3216
|
+
outputRouter: { enabled: true, vaultRoot: join(cwd, "vault") },
|
|
3217
|
+
}),
|
|
3218
|
+
"utf8",
|
|
3219
|
+
);
|
|
3220
|
+
|
|
3221
|
+
const tools = [];
|
|
3222
|
+
const pi = {
|
|
3223
|
+
registerTool(tool) {
|
|
3224
|
+
tools.push(tool);
|
|
3225
|
+
},
|
|
3226
|
+
registerCommand() {},
|
|
3227
|
+
on() {},
|
|
3228
|
+
appendEntry() {},
|
|
3229
|
+
sendUserMessage() {},
|
|
3230
|
+
async exec(_bin, args) {
|
|
3231
|
+
const command = args?.[1] ?? "unknown";
|
|
3232
|
+
const prefix = command.includes("one") ? "ONE" : "TWO";
|
|
3233
|
+
return {
|
|
3234
|
+
stdout:
|
|
3235
|
+
Array.from({ length: 20 }, (_, index) => `${prefix}_VISIBLE_BATCH_SENTINEL_${index + 1}`).join("\n") + "\n",
|
|
3236
|
+
stderr: "",
|
|
3237
|
+
code: 0,
|
|
3238
|
+
killed: false,
|
|
3239
|
+
};
|
|
3240
|
+
},
|
|
3241
|
+
};
|
|
3242
|
+
freeflowExtension(pi);
|
|
3243
|
+
const runTool = tools.find((tool) => tool.name === "freeflow_run");
|
|
3244
|
+
const batchTool = tools.find((tool) => tool.name === "freeflow_batch");
|
|
3245
|
+
assert.ok(runTool);
|
|
3246
|
+
assert.ok(batchTool);
|
|
3247
|
+
|
|
3248
|
+
const batchCtx = context(cwd);
|
|
3249
|
+
batchCtx.sessionManager.getSessionId = () => "batch-compact";
|
|
3250
|
+
const batch = await batchTool.execute(
|
|
3251
|
+
"batch-call",
|
|
3252
|
+
{
|
|
3253
|
+
steps: [
|
|
3254
|
+
{ id: "one", kind: "run", input: { command: "fixture one" } },
|
|
3255
|
+
{ id: "two", kind: "run", input: { command: "fixture two" } },
|
|
3256
|
+
],
|
|
3257
|
+
},
|
|
3258
|
+
undefined,
|
|
3259
|
+
undefined,
|
|
3260
|
+
batchCtx,
|
|
3261
|
+
);
|
|
3262
|
+
|
|
3263
|
+
const separateCtx = context(cwd);
|
|
3264
|
+
separateCtx.sessionManager.getSessionId = () => "batch-separate";
|
|
3265
|
+
const separateOne = await runTool.execute("run-one", { command: "fixture one" }, undefined, undefined, separateCtx);
|
|
3266
|
+
const separateTwo = await runTool.execute("run-two", { command: "fixture two" }, undefined, undefined, separateCtx);
|
|
3267
|
+
const separateVisible = `${separateOne.content[0].text}\n${separateTwo.content[0].text}`;
|
|
3268
|
+
const visibleText = batch.content[0].text;
|
|
3269
|
+
|
|
3270
|
+
assert.match(visibleText, /freeflow_batch\|routed/);
|
|
3271
|
+
assert.match(visibleText, /steps=2/);
|
|
3272
|
+
assert.match(visibleText, /details\.result\.steps/);
|
|
3273
|
+
assert.doesNotMatch(visibleText, /^\s*\{/);
|
|
3274
|
+
assert.doesNotMatch(visibleText, /VISIBLE_BATCH_SENTINEL/);
|
|
3275
|
+
assert.ok(Buffer.byteLength(visibleText, "utf8") < Buffer.byteLength(separateVisible, "utf8"));
|
|
3276
|
+
|
|
3277
|
+
const payload = batch.details.result;
|
|
3278
|
+
assert.equal(payload.stepCount, 2);
|
|
3279
|
+
assert.equal(payload.okCount, 2);
|
|
3280
|
+
assert.equal(payload.failedCount, 0);
|
|
3281
|
+
assert.equal(payload.steps[0].result.importantLines[0].excerpt.includes("ONE_VISIBLE_BATCH_SENTINEL_1"), true);
|
|
3282
|
+
assert.equal(payload.steps[1].result.importantLines[0].excerpt.includes("TWO_VISIBLE_BATCH_SENTINEL_1"), true);
|
|
3283
|
+
|
|
3284
|
+
const expanded = renderText(batchTool.renderResult(batch, { expanded: true }, testTheme));
|
|
3285
|
+
assert.match(expanded, /Steps/);
|
|
3286
|
+
assert.match(expanded, /#1 one/);
|
|
3287
|
+
assert.match(expanded, /#2 two/);
|
|
3288
|
+
assert.match(expanded, /details\.result\.steps/);
|
|
3289
|
+
} finally {
|
|
3290
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3291
|
+
}
|
|
3292
|
+
});
|
|
3293
|
+
|
|
3294
|
+
test("Pi freeflow_batch accepts queries and renders compact answers", async () => {
|
|
3295
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-batch-query-text-"));
|
|
3296
|
+
try {
|
|
3297
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3298
|
+
await writeFile(
|
|
3299
|
+
join(cwd, ".freeflow/config.json"),
|
|
3300
|
+
JSON.stringify({
|
|
3301
|
+
defaultMode: "workflow",
|
|
3302
|
+
outputRouter: { enabled: true, vaultRoot: join(cwd, "vault") },
|
|
3303
|
+
}),
|
|
3304
|
+
"utf8",
|
|
3305
|
+
);
|
|
3306
|
+
|
|
3307
|
+
const tools = [];
|
|
3308
|
+
const pi = {
|
|
3309
|
+
registerTool(tool) {
|
|
3310
|
+
tools.push(tool);
|
|
3311
|
+
},
|
|
3312
|
+
registerCommand() {},
|
|
3313
|
+
on() {},
|
|
3314
|
+
appendEntry() {},
|
|
3315
|
+
sendUserMessage() {},
|
|
3316
|
+
async exec() {
|
|
3317
|
+
return {
|
|
3318
|
+
stdout: "query answer source\nBATCH_QUERY_VISIBLE_FACT_99\n",
|
|
3319
|
+
stderr: "",
|
|
3320
|
+
code: 0,
|
|
3321
|
+
killed: false,
|
|
3322
|
+
};
|
|
3323
|
+
},
|
|
3324
|
+
};
|
|
3325
|
+
freeflowExtension(pi);
|
|
3326
|
+
const batchTool = tools.find((tool) => tool.name === "freeflow_batch");
|
|
3327
|
+
assert.ok(batchTool);
|
|
3328
|
+
assert.ok(batchTool.parameters.properties.queries);
|
|
3329
|
+
|
|
3330
|
+
const batchCtx = context(cwd);
|
|
3331
|
+
batchCtx.sessionManager.getSessionId = () => "batch-query-render";
|
|
3332
|
+
const batch = await batchTool.execute(
|
|
3333
|
+
"batch-query-call",
|
|
3334
|
+
{
|
|
3335
|
+
queries: ["BATCH_QUERY_VISIBLE_FACT_99"],
|
|
3336
|
+
steps: [
|
|
3337
|
+
{
|
|
3338
|
+
id: "fact",
|
|
3339
|
+
kind: "run",
|
|
3340
|
+
input: { command: "fixture fact", preserve: "full" },
|
|
3341
|
+
},
|
|
3342
|
+
],
|
|
3343
|
+
},
|
|
3344
|
+
undefined,
|
|
3345
|
+
undefined,
|
|
3346
|
+
batchCtx,
|
|
3347
|
+
);
|
|
3348
|
+
|
|
3349
|
+
const visibleText = batch.content[0].text;
|
|
3350
|
+
assert.match(visibleText, /q\|answered\|BATCH_QUERY_VISIBLE_FACT_99/);
|
|
3351
|
+
assert.match(visibleText, /BATCH_QUERY_VISIBLE_FACT_99/);
|
|
3352
|
+
assert.equal(batch.details.result.queries[0].status, "answered");
|
|
3353
|
+
|
|
3354
|
+
const expanded = renderText(batchTool.renderResult(batch, { expanded: true }, testTheme));
|
|
3355
|
+
assert.match(expanded, /Query answers/);
|
|
3356
|
+
assert.match(expanded, /BATCH_QUERY_VISIBLE_FACT_99/);
|
|
3357
|
+
} finally {
|
|
3358
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3359
|
+
}
|
|
3360
|
+
});
|
|
3361
|
+
|
|
3362
|
+
test("Pi freeflow_run uses outputRouter thresholds and vault root from repo config", async () => {
|
|
3363
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-run-config-"));
|
|
3364
|
+
try {
|
|
3365
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3366
|
+
await writeFile(
|
|
3367
|
+
join(cwd, ".freeflow/config.json"),
|
|
3368
|
+
JSON.stringify({
|
|
3369
|
+
defaultMode: "workflow",
|
|
3370
|
+
outputRouter: {
|
|
3371
|
+
enabled: true,
|
|
3372
|
+
largeOutputLines: 1,
|
|
3373
|
+
largeOutputBytes: 10_000,
|
|
3374
|
+
vaultRoot: join(cwd, "vault"),
|
|
3375
|
+
},
|
|
3376
|
+
}),
|
|
3377
|
+
"utf8",
|
|
3378
|
+
);
|
|
3379
|
+
|
|
3380
|
+
const tools = [];
|
|
3381
|
+
const pi = {
|
|
3382
|
+
registerTool(tool) {
|
|
3383
|
+
tools.push(tool);
|
|
3384
|
+
},
|
|
3385
|
+
registerCommand() {},
|
|
3386
|
+
on() {},
|
|
3387
|
+
appendEntry() {},
|
|
3388
|
+
sendUserMessage() {},
|
|
3389
|
+
async exec() {
|
|
3390
|
+
return { stdout: "one\ntwo\n", stderr: "", code: 0, killed: false };
|
|
3391
|
+
},
|
|
3392
|
+
};
|
|
3393
|
+
freeflowExtension(pi);
|
|
3394
|
+
const runTool = tools.find((tool) => tool.name === "freeflow_run");
|
|
3395
|
+
assert.ok(runTool);
|
|
3396
|
+
|
|
3397
|
+
const result = await runTool.execute("tool-call", { command: "fixture" }, undefined, undefined, context(cwd));
|
|
3398
|
+
const visibleText = result.content[0].text;
|
|
3399
|
+
const payload = result.details.result;
|
|
3400
|
+
|
|
3401
|
+
assert.match(visibleText, /freeflow_run\|success/);
|
|
3402
|
+
assert.match(visibleText, /route=partial/);
|
|
3403
|
+
assert.match(visibleText, /raw=ffout_/);
|
|
3404
|
+
assert.doesNotMatch(visibleText, /^\s*\{/);
|
|
3405
|
+
assert.equal(payload.toolStatus, "ok");
|
|
3406
|
+
assert.equal(payload.routing.status, "partial");
|
|
3407
|
+
assert.ok(payload.recovery.outputId.startsWith("ffout_"));
|
|
3408
|
+
} finally {
|
|
3409
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3410
|
+
}
|
|
3411
|
+
});
|
|
3412
|
+
|
|
3413
|
+
test("Pi freeflow_search applies configured generated path hints", async () => {
|
|
3414
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-generated-path-hints-"));
|
|
3415
|
+
try {
|
|
3416
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3417
|
+
await mkdir(join(cwd, "custom-generated"));
|
|
3418
|
+
await writeFile(
|
|
3419
|
+
join(cwd, ".freeflow/config.json"),
|
|
3420
|
+
JSON.stringify({
|
|
3421
|
+
defaultMode: "workflow",
|
|
3422
|
+
outputRouter: {
|
|
3423
|
+
enabled: true,
|
|
3424
|
+
generatedPaths: ["custom-generated/**"],
|
|
3425
|
+
},
|
|
3426
|
+
}),
|
|
3427
|
+
"utf8",
|
|
3428
|
+
);
|
|
3429
|
+
await writeFile(join(cwd, "target.md"), "PI_GENERATED_HINT_MARKER source truth", "utf8");
|
|
3430
|
+
await writeFile(
|
|
3431
|
+
join(cwd, "custom-generated", "decoy.md"),
|
|
3432
|
+
`${"PI_GENERATED_HINT_MARKER source truth ".repeat(1000)}pihintsentinel`,
|
|
3433
|
+
"utf8",
|
|
3434
|
+
);
|
|
3435
|
+
|
|
3436
|
+
const { tools } = loadExtension();
|
|
3437
|
+
const searchTool = tools.find((tool) => tool.name === "freeflow_search");
|
|
3438
|
+
assert.ok(searchTool);
|
|
3439
|
+
|
|
3440
|
+
const broad = await searchTool.execute(
|
|
3441
|
+
"search-generated-hints",
|
|
3442
|
+
{
|
|
3443
|
+
action: "query",
|
|
3444
|
+
source: { kind: "repo" },
|
|
3445
|
+
query: "PI_GENERATED_HINT_MARKER source truth",
|
|
3446
|
+
},
|
|
3447
|
+
undefined,
|
|
3448
|
+
undefined,
|
|
3449
|
+
context(cwd),
|
|
3450
|
+
);
|
|
3451
|
+
const broadVisible = broad.content[0].text;
|
|
3452
|
+
const broadPayload = broad.details.result;
|
|
3453
|
+
assert.match(broadVisible, /freeflow_search\|routed/);
|
|
3454
|
+
assert.doesNotMatch(broadVisible, /^\s*\{/);
|
|
3455
|
+
assert.ok(
|
|
3456
|
+
Buffer.byteLength(broadVisible, "utf8") < Buffer.byteLength(JSON.stringify(broadPayload, null, 2), "utf8"),
|
|
3457
|
+
);
|
|
3458
|
+
assert.equal(broadPayload.evidence[0].path, "target.md");
|
|
3459
|
+
assert.doesNotMatch(broadPayload.evidence[0].excerpt, /pihintsentinel/);
|
|
3460
|
+
|
|
3461
|
+
const explicit = await searchTool.execute(
|
|
3462
|
+
"search-generated-explicit",
|
|
3463
|
+
{
|
|
3464
|
+
action: "query",
|
|
3465
|
+
source: { kind: "repo", path: "custom-generated/decoy.md" },
|
|
3466
|
+
query: "pihintsentinel",
|
|
3467
|
+
},
|
|
3468
|
+
undefined,
|
|
3469
|
+
undefined,
|
|
3470
|
+
context(cwd),
|
|
3471
|
+
);
|
|
3472
|
+
const explicitPayload = explicit.details.result;
|
|
3473
|
+
assert.equal(explicitPayload.evidence[0].path, "custom-generated/decoy.md");
|
|
3474
|
+
} finally {
|
|
3475
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3476
|
+
}
|
|
3477
|
+
});
|
|
3478
|
+
|
|
3479
|
+
test("Pi freeflow_search supports vault-wide query without outputId", async () => {
|
|
3480
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-vault-wide-query-"));
|
|
3481
|
+
try {
|
|
3482
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3483
|
+
const vaultRoot = join(cwd, "vault");
|
|
3484
|
+
await writeFile(
|
|
3485
|
+
join(cwd, ".freeflow/config.json"),
|
|
3486
|
+
JSON.stringify({
|
|
3487
|
+
defaultMode: "workflow",
|
|
3488
|
+
outputRouter: { enabled: true, vaultRoot },
|
|
3489
|
+
}),
|
|
3490
|
+
"utf8",
|
|
3491
|
+
);
|
|
3492
|
+
|
|
3493
|
+
const sessionId = "pi-vault-wide-query-session";
|
|
3494
|
+
const stored = await storeTextOutput(createVault({ root: vaultRoot }), {
|
|
3495
|
+
sessionId,
|
|
3496
|
+
sourceKind: "mcp",
|
|
3497
|
+
raw: "PI_VAULT_WIDE_TARGET through registered Pi tool",
|
|
3498
|
+
producer: { kind: "mcp", server: "github", tool: "search_issues" },
|
|
3499
|
+
createdAt: "2026-06-16T00:00:00.000Z",
|
|
3500
|
+
});
|
|
3501
|
+
|
|
3502
|
+
const { tools } = loadExtension();
|
|
3503
|
+
const searchTool = tools.find((tool) => tool.name === "freeflow_search");
|
|
3504
|
+
assert.ok(searchTool);
|
|
3505
|
+
const ctx = context(cwd);
|
|
3506
|
+
ctx.sessionManager.getSessionId = () => sessionId;
|
|
3507
|
+
|
|
3508
|
+
const result = await searchTool.execute(
|
|
3509
|
+
"search-vault-wide-query",
|
|
3510
|
+
{
|
|
3511
|
+
action: "query",
|
|
3512
|
+
source: { kind: "vault" },
|
|
3513
|
+
query: "PI_VAULT_WIDE_TARGET",
|
|
3514
|
+
filters: { producerKind: "mcp", server: "github" },
|
|
3515
|
+
},
|
|
3516
|
+
undefined,
|
|
3517
|
+
undefined,
|
|
3518
|
+
ctx,
|
|
3519
|
+
);
|
|
3520
|
+
const visibleText = result.content[0].text;
|
|
3521
|
+
const payload = result.details.result;
|
|
3522
|
+
assert.match(visibleText, /freeflow_search\|routed/);
|
|
3523
|
+
assert.match(visibleText, /PI_VAULT_WIDE_TARGET/);
|
|
3524
|
+
assert.doesNotMatch(visibleText, /^\s*\{/);
|
|
3525
|
+
assert.equal(payload.toolStatus, "ok");
|
|
3526
|
+
assert.equal(payload.evidence[0].source.outputId, stored.outputId);
|
|
3527
|
+
assert.equal(payload.evidence[0].source.stream, "raw");
|
|
3528
|
+
} finally {
|
|
3529
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3530
|
+
}
|
|
3531
|
+
});
|
|
3532
|
+
|
|
3533
|
+
test("Pi reports invalid outputRouter config warnings", async () => {
|
|
3534
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-invalid-config-"));
|
|
3535
|
+
try {
|
|
3536
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3537
|
+
await writeFile(
|
|
3538
|
+
join(cwd, ".freeflow/config.json"),
|
|
3539
|
+
JSON.stringify({
|
|
3540
|
+
defaultMode: "workflow",
|
|
3541
|
+
outputRouter: { postToolRouting: "always" },
|
|
3542
|
+
}),
|
|
3543
|
+
"utf8",
|
|
3544
|
+
);
|
|
3545
|
+
|
|
3546
|
+
const { handlers } = loadExtension();
|
|
3547
|
+
const notifications = [];
|
|
3548
|
+
const ctx = context(cwd);
|
|
3549
|
+
ctx.ui.notify = (message, level) => notifications.push({ message, level });
|
|
3550
|
+
await handlers.get("session_start")({}, ctx);
|
|
3551
|
+
|
|
3552
|
+
assert.equal(notifications[0].level, "warning");
|
|
3553
|
+
assert.match(notifications[0].message, /postToolRouting/);
|
|
3554
|
+
} finally {
|
|
3555
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3556
|
+
}
|
|
3557
|
+
});
|
|
3558
|
+
|
|
3559
|
+
test("Pi observed routing vaults and labels configured MCP output before native safety net", async () => {
|
|
3560
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-observed-mcp-"));
|
|
3561
|
+
try {
|
|
3562
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3563
|
+
await writeFile(
|
|
3564
|
+
join(cwd, ".freeflow/config.json"),
|
|
3565
|
+
JSON.stringify({
|
|
3566
|
+
defaultMode: "workflow",
|
|
3567
|
+
outputRouter: {
|
|
3568
|
+
enabled: true,
|
|
3569
|
+
vaultRoot: join(cwd, "vault"),
|
|
3570
|
+
observedRouting: {
|
|
3571
|
+
enabled: true,
|
|
3572
|
+
mcp: {
|
|
3573
|
+
servers: { github: { enabled: true, persistence: "exact" } },
|
|
3574
|
+
},
|
|
3575
|
+
},
|
|
3576
|
+
},
|
|
3577
|
+
}),
|
|
3578
|
+
"utf8",
|
|
3579
|
+
);
|
|
3580
|
+
|
|
3581
|
+
const { handlers } = loadExtension();
|
|
3582
|
+
const toolResult = handlers.get("tool_result");
|
|
3583
|
+
const result = await toolResult(
|
|
3584
|
+
{
|
|
3585
|
+
type: "tool_result",
|
|
3586
|
+
toolName: "mcp",
|
|
3587
|
+
toolCallId: "mcp-1",
|
|
3588
|
+
input: { server: "github", tool: "search_issues" },
|
|
3589
|
+
content: [
|
|
3590
|
+
{
|
|
3591
|
+
type: "text",
|
|
3592
|
+
text: JSON.stringify({
|
|
3593
|
+
items: [
|
|
3594
|
+
{
|
|
3595
|
+
id: 1,
|
|
3596
|
+
title: "Alpha",
|
|
3597
|
+
html_url: "https://github.com/acme/repo/issues/1",
|
|
3598
|
+
body: "x".repeat(500),
|
|
3599
|
+
},
|
|
3600
|
+
{
|
|
3601
|
+
id: 2,
|
|
3602
|
+
title: "Beta",
|
|
3603
|
+
html_url: "https://github.com/acme/repo/issues/2",
|
|
3604
|
+
body: "y".repeat(500),
|
|
3605
|
+
},
|
|
3606
|
+
{
|
|
3607
|
+
id: 3,
|
|
3608
|
+
title: "Gamma",
|
|
3609
|
+
html_url: "https://github.com/acme/repo/issues/3",
|
|
3610
|
+
body: "z".repeat(500),
|
|
3611
|
+
},
|
|
3612
|
+
{
|
|
3613
|
+
id: 4,
|
|
3614
|
+
title: "Delta",
|
|
3615
|
+
html_url: "https://github.com/acme/repo/issues/4",
|
|
3616
|
+
body: "w".repeat(500),
|
|
3617
|
+
},
|
|
3618
|
+
],
|
|
3619
|
+
}),
|
|
3620
|
+
},
|
|
3621
|
+
],
|
|
3622
|
+
details: undefined,
|
|
3623
|
+
isError: false,
|
|
3624
|
+
},
|
|
3625
|
+
context(cwd),
|
|
3626
|
+
);
|
|
3627
|
+
|
|
3628
|
+
assert.ok(result);
|
|
3629
|
+
assert.match(result.content[0].text, /Freeflow routed this observed mcp result/);
|
|
3630
|
+
assert.match(result.content[0].text, /outputId=ffout_/);
|
|
3631
|
+
assert.match(result.content[0].text, /Alpha/);
|
|
3632
|
+
assert.match(result.content[0].text, /https:\/\/github\.com\/acme\/repo\/issues\/1/);
|
|
3633
|
+
assert.doesNotMatch(result.content[0].text, /xxxxxxxxxxxxxxxx/);
|
|
3634
|
+
assert.equal(result.details.freeflowObservedRouting.route, "observed");
|
|
3635
|
+
assert.equal(result.details.freeflowObservedRouting.producer.server, "github");
|
|
3636
|
+
} finally {
|
|
3637
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3638
|
+
}
|
|
3639
|
+
});
|
|
3640
|
+
|
|
3641
|
+
test("Pi observed routing leaves disabled MCP producer result unchanged", async () => {
|
|
3642
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-observed-disabled-"));
|
|
3643
|
+
try {
|
|
3644
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3645
|
+
await writeFile(
|
|
3646
|
+
join(cwd, ".freeflow/config.json"),
|
|
3647
|
+
JSON.stringify({
|
|
3648
|
+
defaultMode: "workflow",
|
|
3649
|
+
outputRouter: {
|
|
3650
|
+
enabled: true,
|
|
3651
|
+
observedRouting: {
|
|
3652
|
+
enabled: true,
|
|
3653
|
+
mcp: { servers: { github: { enabled: false } } },
|
|
3654
|
+
},
|
|
3655
|
+
},
|
|
3656
|
+
}),
|
|
3657
|
+
"utf8",
|
|
3658
|
+
);
|
|
3659
|
+
|
|
3660
|
+
const { handlers } = loadExtension();
|
|
3661
|
+
const result = await handlers.get("tool_result")(
|
|
3662
|
+
{
|
|
3663
|
+
type: "tool_result",
|
|
3664
|
+
toolName: "mcp",
|
|
3665
|
+
toolCallId: "mcp-disabled",
|
|
3666
|
+
input: { server: "github", tool: "search_issues" },
|
|
3667
|
+
content: [{ type: "text", text: "unchanged" }],
|
|
3668
|
+
details: undefined,
|
|
3669
|
+
isError: false,
|
|
3670
|
+
},
|
|
3671
|
+
context(cwd),
|
|
3672
|
+
);
|
|
3673
|
+
|
|
3674
|
+
assert.equal(result, undefined);
|
|
3675
|
+
} finally {
|
|
3676
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3677
|
+
}
|
|
3678
|
+
});
|
|
3679
|
+
|
|
3680
|
+
test("Pi observed routing fails open without losing MCP output", async () => {
|
|
3681
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-observed-fail-open-"));
|
|
3682
|
+
try {
|
|
3683
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3684
|
+
const fileVault = join(cwd, "vault-file");
|
|
3685
|
+
await writeFile(fileVault, "not a directory", "utf8");
|
|
3686
|
+
await writeFile(
|
|
3687
|
+
join(cwd, ".freeflow/config.json"),
|
|
3688
|
+
JSON.stringify({
|
|
3689
|
+
defaultMode: "workflow",
|
|
3690
|
+
outputRouter: {
|
|
3691
|
+
enabled: true,
|
|
3692
|
+
vaultRoot: fileVault,
|
|
3693
|
+
observedRouting: {
|
|
3694
|
+
enabled: true,
|
|
3695
|
+
mcp: {
|
|
3696
|
+
servers: { github: { enabled: true, persistence: "exact" } },
|
|
3697
|
+
},
|
|
3698
|
+
},
|
|
3699
|
+
},
|
|
3700
|
+
}),
|
|
3701
|
+
"utf8",
|
|
3702
|
+
);
|
|
3703
|
+
|
|
3704
|
+
const { handlers } = loadExtension();
|
|
3705
|
+
const result = await handlers.get("tool_result")(
|
|
3706
|
+
{
|
|
3707
|
+
type: "tool_result",
|
|
3708
|
+
toolName: "mcp",
|
|
3709
|
+
toolCallId: "mcp-fail-open",
|
|
3710
|
+
input: { server: "github", tool: "search_issues" },
|
|
3711
|
+
content: [{ type: "text", text: "original mcp output survives" }],
|
|
3712
|
+
details: undefined,
|
|
3713
|
+
isError: false,
|
|
3714
|
+
},
|
|
3715
|
+
context(cwd),
|
|
3716
|
+
);
|
|
3717
|
+
|
|
3718
|
+
assert.ok(result);
|
|
3719
|
+
assert.match(result.content[0].text, /Freeflow observed-routing warning/);
|
|
3720
|
+
assert.match(result.content[0].text, /original mcp output survives/);
|
|
3721
|
+
assert.equal(result.details.freeflowObservedRouting.routingStatus, "failed");
|
|
3722
|
+
} finally {
|
|
3723
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3724
|
+
}
|
|
3725
|
+
});
|
|
3726
|
+
|
|
3727
|
+
test("Pi post-tool safety net passes native output unchanged when config is off", async () => {
|
|
3728
|
+
const { handlers } = loadExtension();
|
|
3729
|
+
const toolResult = handlers.get("tool_result");
|
|
3730
|
+
assert.ok(toolResult);
|
|
3731
|
+
|
|
3732
|
+
const raw = Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n");
|
|
3733
|
+
const result = await toolResult(
|
|
3734
|
+
{
|
|
3735
|
+
type: "tool_result",
|
|
3736
|
+
toolName: "read",
|
|
3737
|
+
toolCallId: "read-1",
|
|
3738
|
+
input: { path: "large.txt" },
|
|
3739
|
+
content: [{ type: "text", text: raw }],
|
|
3740
|
+
details: undefined,
|
|
3741
|
+
isError: false,
|
|
3742
|
+
},
|
|
3743
|
+
context(),
|
|
3744
|
+
);
|
|
3745
|
+
|
|
3746
|
+
assert.equal(result, undefined);
|
|
3747
|
+
});
|
|
3748
|
+
|
|
3749
|
+
test("Pi post-tool safety net vaults and labels large native bash output when enabled", async () => {
|
|
3750
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-safety-net-"));
|
|
3751
|
+
try {
|
|
3752
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3753
|
+
await writeFile(
|
|
3754
|
+
join(cwd, ".freeflow/config.json"),
|
|
3755
|
+
JSON.stringify({
|
|
3756
|
+
defaultMode: "workflow",
|
|
3757
|
+
outputRouter: {
|
|
3758
|
+
enabled: true,
|
|
3759
|
+
postToolRouting: "safety-net",
|
|
3760
|
+
largeOutputLines: 3,
|
|
3761
|
+
largeOutputBytes: 100_000,
|
|
3762
|
+
vaultRoot: join(cwd, "vault"),
|
|
3763
|
+
},
|
|
3764
|
+
}),
|
|
3765
|
+
"utf8",
|
|
3766
|
+
);
|
|
3767
|
+
|
|
3768
|
+
const { handlers, tools } = loadExtension();
|
|
3769
|
+
const toolResult = handlers.get("tool_result");
|
|
3770
|
+
const raw = Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n");
|
|
3771
|
+
const result = await toolResult(
|
|
3772
|
+
{
|
|
3773
|
+
type: "tool_result",
|
|
3774
|
+
toolName: "bash",
|
|
3775
|
+
toolCallId: "bash-1",
|
|
3776
|
+
input: { command: "npm test" },
|
|
3777
|
+
content: [{ type: "text", text: raw }],
|
|
3778
|
+
details: undefined,
|
|
3779
|
+
isError: false,
|
|
3780
|
+
},
|
|
3781
|
+
context(cwd),
|
|
3782
|
+
);
|
|
3783
|
+
|
|
3784
|
+
assert.ok(result);
|
|
3785
|
+
const routedText = result.content[0].text;
|
|
3786
|
+
assert.match(routedText, /Freeflow routed this native bash result/);
|
|
3787
|
+
assert.match(routedText, /outputId=ffout_/);
|
|
3788
|
+
assert.doesNotMatch(routedText, /line 20/);
|
|
3789
|
+
|
|
3790
|
+
const outputId = routedText.match(/outputId=(ffout_[a-f0-9]+)/)?.[1];
|
|
3791
|
+
assert.ok(outputId);
|
|
3792
|
+
const searchTool = tools.find((tool) => tool.name === "freeflow_search");
|
|
3793
|
+
const searched = await searchTool.execute(
|
|
3794
|
+
"search-1",
|
|
3795
|
+
{
|
|
3796
|
+
action: "retrieve",
|
|
3797
|
+
source: { kind: "vault", outputId, stream: "raw" },
|
|
3798
|
+
lineRange: { start: 18, end: 20 },
|
|
3799
|
+
},
|
|
3800
|
+
undefined,
|
|
3801
|
+
undefined,
|
|
3802
|
+
context(cwd),
|
|
3803
|
+
);
|
|
3804
|
+
const payload = searched.details.result;
|
|
3805
|
+
assert.doesNotMatch(searched.content[0].text, /^\s*\{/);
|
|
3806
|
+
assert.equal(payload.evidence[0].excerpt, "line 18\nline 19\nline 20");
|
|
3807
|
+
} finally {
|
|
3808
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3809
|
+
}
|
|
3810
|
+
});
|
|
3811
|
+
|
|
3812
|
+
test("Pi post-tool safety net notes exact duplicate native output", async () => {
|
|
3813
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-safety-net-duplicate-"));
|
|
3814
|
+
try {
|
|
3815
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3816
|
+
await writeFile(
|
|
3817
|
+
join(cwd, ".freeflow/config.json"),
|
|
3818
|
+
JSON.stringify({
|
|
3819
|
+
defaultMode: "workflow",
|
|
3820
|
+
outputRouter: {
|
|
3821
|
+
enabled: true,
|
|
3822
|
+
postToolRouting: "safety-net",
|
|
3823
|
+
largeOutputLines: 3,
|
|
3824
|
+
largeOutputBytes: 100_000,
|
|
3825
|
+
vaultRoot: join(cwd, "vault"),
|
|
3826
|
+
},
|
|
3827
|
+
}),
|
|
3828
|
+
"utf8",
|
|
3829
|
+
);
|
|
3830
|
+
|
|
3831
|
+
const { handlers } = loadExtension();
|
|
3832
|
+
const toolResult = handlers.get("tool_result");
|
|
3833
|
+
const raw = Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n");
|
|
3834
|
+
const first = await toolResult(
|
|
3835
|
+
{
|
|
3836
|
+
type: "tool_result",
|
|
3837
|
+
toolName: "bash",
|
|
3838
|
+
toolCallId: "bash-duplicate-1",
|
|
3839
|
+
input: { command: "npm test" },
|
|
3840
|
+
content: [{ type: "text", text: raw }],
|
|
3841
|
+
details: undefined,
|
|
3842
|
+
isError: false,
|
|
3843
|
+
},
|
|
3844
|
+
context(cwd),
|
|
3845
|
+
);
|
|
3846
|
+
const firstOutputId = first.content[0].text.match(/outputId=(ffout_[a-f0-9]+)/)?.[1];
|
|
3847
|
+
assert.ok(firstOutputId);
|
|
3848
|
+
|
|
3849
|
+
const second = await toolResult(
|
|
3850
|
+
{
|
|
3851
|
+
type: "tool_result",
|
|
3852
|
+
toolName: "bash",
|
|
3853
|
+
toolCallId: "bash-duplicate-2",
|
|
3854
|
+
input: { command: "npm test" },
|
|
3855
|
+
content: [{ type: "text", text: raw }],
|
|
3856
|
+
details: undefined,
|
|
3857
|
+
isError: false,
|
|
3858
|
+
},
|
|
3859
|
+
context(cwd),
|
|
3860
|
+
);
|
|
3861
|
+
|
|
3862
|
+
assert.match(
|
|
3863
|
+
second.content[0].text,
|
|
3864
|
+
new RegExp(`Duplicate: exact native output matches previous outputId=${firstOutputId}`),
|
|
3865
|
+
);
|
|
3866
|
+
assert.equal(second.details.freeflowOutputRouter.duplicateOfOutputId, firstOutputId);
|
|
3867
|
+
assert.match(second.content[0].text, /current raw output was vaulted as outputId=ffout_/);
|
|
3868
|
+
} finally {
|
|
3869
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3870
|
+
}
|
|
3871
|
+
});
|
|
3872
|
+
|
|
3873
|
+
test("Pi post-tool safety net leaves small native output alone when enabled", async () => {
|
|
3874
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-small-output-"));
|
|
3875
|
+
try {
|
|
3876
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3877
|
+
await writeFile(
|
|
3878
|
+
join(cwd, ".freeflow/config.json"),
|
|
3879
|
+
JSON.stringify({
|
|
3880
|
+
defaultMode: "workflow",
|
|
3881
|
+
outputRouter: {
|
|
3882
|
+
enabled: true,
|
|
3883
|
+
postToolRouting: "safety-net",
|
|
3884
|
+
largeOutputLines: 100,
|
|
3885
|
+
largeOutputBytes: 10_000,
|
|
3886
|
+
},
|
|
3887
|
+
}),
|
|
3888
|
+
"utf8",
|
|
3889
|
+
);
|
|
3890
|
+
|
|
3891
|
+
const { handlers } = loadExtension();
|
|
3892
|
+
const result = await handlers.get("tool_result")(
|
|
3893
|
+
{
|
|
3894
|
+
type: "tool_result",
|
|
3895
|
+
toolName: "bash",
|
|
3896
|
+
toolCallId: "bash-small",
|
|
3897
|
+
input: { command: "pwd" },
|
|
3898
|
+
content: [{ type: "text", text: "small output" }],
|
|
3899
|
+
details: undefined,
|
|
3900
|
+
isError: false,
|
|
3901
|
+
},
|
|
3902
|
+
context(cwd),
|
|
3903
|
+
);
|
|
3904
|
+
|
|
3905
|
+
assert.equal(result, undefined);
|
|
3906
|
+
} finally {
|
|
3907
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3908
|
+
}
|
|
3909
|
+
});
|
|
3910
|
+
|
|
3911
|
+
test("Pi post-tool safety net fails open without losing native output", async () => {
|
|
3912
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-safety-fail-open-"));
|
|
3913
|
+
try {
|
|
3914
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3915
|
+
const blockedVaultPath = join(cwd, "vault-file");
|
|
3916
|
+
await writeFile(blockedVaultPath, "not a directory", "utf8");
|
|
3917
|
+
await writeFile(
|
|
3918
|
+
join(cwd, ".freeflow/config.json"),
|
|
3919
|
+
JSON.stringify({
|
|
3920
|
+
defaultMode: "workflow",
|
|
3921
|
+
outputRouter: {
|
|
3922
|
+
enabled: true,
|
|
3923
|
+
postToolRouting: "safety-net",
|
|
3924
|
+
largeOutputLines: 1,
|
|
3925
|
+
largeOutputBytes: 1,
|
|
3926
|
+
vaultRoot: blockedVaultPath,
|
|
3927
|
+
},
|
|
3928
|
+
}),
|
|
3929
|
+
"utf8",
|
|
3930
|
+
);
|
|
3931
|
+
|
|
3932
|
+
const { handlers } = loadExtension();
|
|
3933
|
+
const raw = "line 1\nline 2";
|
|
3934
|
+
const result = await handlers.get("tool_result")(
|
|
3935
|
+
{
|
|
3936
|
+
type: "tool_result",
|
|
3937
|
+
toolName: "read",
|
|
3938
|
+
toolCallId: "read-fail-open",
|
|
3939
|
+
input: { path: "large.txt" },
|
|
3940
|
+
content: [{ type: "text", text: raw }],
|
|
3941
|
+
details: undefined,
|
|
3942
|
+
isError: false,
|
|
3943
|
+
},
|
|
3944
|
+
context(cwd),
|
|
3945
|
+
);
|
|
3946
|
+
|
|
3947
|
+
assert.ok(result);
|
|
3948
|
+
assert.deepEqual(result.content[0], { type: "text", text: raw });
|
|
3949
|
+
assert.match(result.content.at(-1).text, /Freeflow safety-net warning/);
|
|
3950
|
+
} finally {
|
|
3951
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3952
|
+
}
|
|
3953
|
+
});
|
|
3954
|
+
|
|
3955
|
+
test("Pi appends the interaction contract after existing project context", async () => {
|
|
3956
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-already-core-"));
|
|
3957
|
+
try {
|
|
3958
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3959
|
+
await writeFile(
|
|
3960
|
+
join(cwd, ".freeflow/config.json"),
|
|
3961
|
+
JSON.stringify({
|
|
3962
|
+
defaultMode: "workflow",
|
|
3963
|
+
outputRouter: { enabled: true },
|
|
3964
|
+
}),
|
|
3965
|
+
"utf8",
|
|
3966
|
+
);
|
|
3967
|
+
const { handlers } = loadExtension();
|
|
3968
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
3969
|
+
|
|
3970
|
+
const existingPrompt = "# Project Instructions\n\nKeep existing repo guidance.";
|
|
3971
|
+
const result = await beforeAgentStart({ systemPrompt: existingPrompt }, context(cwd));
|
|
3972
|
+
|
|
3973
|
+
assert.match(result.systemPrompt, /^# Project Instructions/);
|
|
3974
|
+
assert.match(result.systemPrompt, /# Freeflow Interaction Contract/);
|
|
3975
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Runtime Kernel/);
|
|
3976
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Workflow Skill/);
|
|
3977
|
+
assert.doesNotMatch(result.systemPrompt, /## Discovery-light/);
|
|
3978
|
+
assert.match(result.systemPrompt, /## Loaded Output Router Skill/);
|
|
3979
|
+
assert.match(result.systemPrompt, /freeflow_search/);
|
|
3980
|
+
assert.match(result.systemPrompt, /freeflow_run/);
|
|
3981
|
+
assert.match(result.systemPrompt, /freeflow_search action=transform/);
|
|
3982
|
+
assert.doesNotMatch(result.systemPrompt, /## Freeflow Output Router Reminder/);
|
|
3983
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Output Router Safety Policy/);
|
|
3984
|
+
} finally {
|
|
3985
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3986
|
+
}
|
|
3987
|
+
});
|
|
3988
|
+
|
|
3989
|
+
test("Pi keeps enabled capability context alongside the interaction contract", async () => {
|
|
3990
|
+
const cwd = await mkdtemp(join(tmpdir(), "freeflow-pi-already-full-"));
|
|
3991
|
+
try {
|
|
3992
|
+
await mkdir(join(cwd, ".freeflow"));
|
|
3993
|
+
await writeFile(
|
|
3994
|
+
join(cwd, ".freeflow/config.json"),
|
|
3995
|
+
JSON.stringify({
|
|
3996
|
+
defaultMode: "workflow",
|
|
3997
|
+
outputRouter: { enabled: true },
|
|
3998
|
+
}),
|
|
3999
|
+
"utf8",
|
|
4000
|
+
);
|
|
4001
|
+
const { handlers } = loadExtension();
|
|
4002
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
4003
|
+
|
|
4004
|
+
const result = await beforeAgentStart({ systemPrompt: "base prompt" }, context(cwd));
|
|
4005
|
+
|
|
4006
|
+
assert.match(result.systemPrompt, /# Freeflow Interaction Contract/);
|
|
4007
|
+
assert.doesNotMatch(result.systemPrompt, /# Freeflow Runtime Kernel/);
|
|
4008
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Workflow Skill/);
|
|
4009
|
+
assert.doesNotMatch(result.systemPrompt, /## Discovery-light/);
|
|
4010
|
+
assert.match(result.systemPrompt, /## Loaded Output Router Skill/);
|
|
4011
|
+
assert.match(result.systemPrompt, /name: output-router/);
|
|
4012
|
+
assert.doesNotMatch(result.systemPrompt, /## Freeflow Output Router Reminder/);
|
|
4013
|
+
assert.doesNotMatch(result.systemPrompt, /## Loaded Output Router Safety Policy/);
|
|
4014
|
+
assert.doesNotMatch(result.systemPrompt, /Capture raw evidence before transformation/);
|
|
4015
|
+
} finally {
|
|
4016
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4017
|
+
}
|
|
4018
|
+
});
|