gsd-pi 2.41.0-dev.0acbce9 → 2.41.0-dev.3557dc4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-web-branch.d.ts +6 -0
- package/dist/cli-web-branch.js +17 -0
- package/dist/onboarding.js +2 -1
- package/dist/resources/extensions/gsd/auto/loop.js +9 -1
- package/dist/resources/extensions/gsd/auto/phases.js +26 -8
- package/dist/resources/extensions/gsd/auto-dashboard.js +6 -2
- package/dist/resources/extensions/gsd/auto-dispatch.js +19 -2
- package/dist/resources/extensions/gsd/auto-post-unit.js +7 -0
- package/dist/resources/extensions/gsd/auto-recovery.js +12 -4
- package/dist/resources/extensions/gsd/auto-start.js +8 -3
- package/dist/resources/extensions/gsd/auto-worktree.js +147 -13
- package/dist/resources/extensions/gsd/auto.js +36 -1
- package/dist/resources/extensions/gsd/bootstrap/db-tools.js +199 -164
- package/dist/resources/extensions/gsd/bootstrap/journal-tools.js +62 -0
- package/dist/resources/extensions/gsd/bootstrap/register-extension.js +2 -0
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +16 -0
- package/dist/resources/extensions/gsd/commands/catalog.js +8 -1
- package/dist/resources/extensions/gsd/commands/handlers/core.js +1 -0
- package/dist/resources/extensions/gsd/commands/handlers/ops.js +5 -0
- package/dist/resources/extensions/gsd/context-store.js +4 -3
- package/dist/resources/extensions/gsd/db-writer.js +5 -2
- package/dist/resources/extensions/gsd/detection.js +1 -1
- package/dist/resources/extensions/gsd/doctor.js +11 -1
- package/dist/resources/extensions/gsd/exit-command.js +12 -2
- package/dist/resources/extensions/gsd/export.js +9 -13
- package/dist/resources/extensions/gsd/extension-manifest.json +2 -2
- package/dist/resources/extensions/gsd/files.js +28 -11
- package/dist/resources/extensions/gsd/forensics.js +10 -3
- package/dist/resources/extensions/gsd/git-service.js +5 -1
- package/dist/resources/extensions/gsd/gsd-db.js +25 -8
- package/dist/resources/extensions/gsd/guided-flow-queue.js +1 -1
- package/dist/resources/extensions/gsd/guided-flow.js +7 -3
- package/dist/resources/extensions/gsd/journal.js +85 -0
- package/dist/resources/extensions/gsd/md-importer.js +5 -0
- package/dist/resources/extensions/gsd/milestone-ids.js +1 -1
- package/dist/resources/extensions/gsd/native-git-bridge.js +2 -2
- package/dist/resources/extensions/gsd/post-unit-hooks.js +24 -412
- package/dist/resources/extensions/gsd/preferences-types.js +1 -0
- package/dist/resources/extensions/gsd/preferences.js +1 -0
- package/dist/resources/extensions/gsd/prompt-loader.js +34 -4
- package/dist/resources/extensions/gsd/prompts/complete-milestone.md +11 -10
- package/dist/resources/extensions/gsd/prompts/discuss-headless.md +2 -2
- package/dist/resources/extensions/gsd/prompts/discuss.md +1 -1
- package/dist/resources/extensions/gsd/prompts/queue.md +1 -1
- package/dist/resources/extensions/gsd/repo-identity.js +46 -2
- package/dist/resources/extensions/gsd/rule-registry.js +489 -0
- package/dist/resources/extensions/gsd/rule-types.js +6 -0
- package/dist/resources/extensions/gsd/service-tier.js +138 -0
- package/dist/resources/extensions/gsd/structured-data-formatter.js +2 -1
- package/dist/resources/extensions/gsd/templates/decisions.md +2 -2
- package/dist/resources/extensions/gsd/workflow-templates.js +13 -1
- package/dist/resources/extensions/gsd/worktree-manager.js +20 -6
- package/dist/resources/extensions/gsd/worktree-resolver.js +19 -2
- package/dist/resources/extensions/subagent/index.js +7 -3
- package/dist/resources/extensions/voice/index.js +4 -4
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +13 -13
- package/dist/web/standalone/.next/build-manifest.json +3 -3
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/captures/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/cleanup/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/doctor/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/export-data/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/forensics/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/history/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/hooks/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/recovery/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/settings-data/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/skill-health/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/terminal/input/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/terminal/resize/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/undo/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/visualizer/route.js +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +13 -13
- package/dist/web/standalone/.next/server/chunks/229.js +3 -3
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +2 -2
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/web/standalone/.next/static/chunks/4024.c195dc1fdd2adbea.js +9 -0
- package/dist/web/standalone/.next/static/chunks/{webpack-9afaaebf6042a1d7.js → webpack-fa307370fcf9fb2c.js} +1 -1
- package/dist/web-mode.d.ts +2 -0
- package/dist/web-mode.js +29 -7
- package/package.json +1 -1
- package/packages/native/src/__tests__/text.test.mjs +33 -0
- package/packages/pi-coding-agent/dist/core/discovery-cache.test.js +3 -1
- package/packages/pi-coding-agent/dist/core/discovery-cache.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.js +10 -7
- package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.js.map +1 -1
- package/packages/pi-coding-agent/src/core/discovery-cache.test.ts +4 -2
- package/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts +11 -7
- package/src/resources/extensions/gsd/auto/loop-deps.ts +5 -1
- package/src/resources/extensions/gsd/auto/loop.ts +10 -1
- package/src/resources/extensions/gsd/auto/phases.ts +28 -8
- package/src/resources/extensions/gsd/auto/types.ts +4 -0
- package/src/resources/extensions/gsd/auto-dashboard.ts +7 -2
- package/src/resources/extensions/gsd/auto-dispatch.ts +25 -5
- package/src/resources/extensions/gsd/auto-post-unit.ts +8 -0
- package/src/resources/extensions/gsd/auto-recovery.ts +12 -4
- package/src/resources/extensions/gsd/auto-start.ts +8 -3
- package/src/resources/extensions/gsd/auto-worktree.ts +162 -18
- package/src/resources/extensions/gsd/auto.ts +40 -1
- package/src/resources/extensions/gsd/bootstrap/db-tools.ts +209 -162
- package/src/resources/extensions/gsd/bootstrap/journal-tools.ts +62 -0
- package/src/resources/extensions/gsd/bootstrap/register-extension.ts +2 -0
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +13 -0
- package/src/resources/extensions/gsd/commands/catalog.ts +8 -1
- package/src/resources/extensions/gsd/commands/handlers/core.ts +1 -0
- package/src/resources/extensions/gsd/commands/handlers/ops.ts +5 -0
- package/src/resources/extensions/gsd/context-store.ts +4 -3
- package/src/resources/extensions/gsd/db-writer.ts +6 -2
- package/src/resources/extensions/gsd/detection.ts +1 -1
- package/src/resources/extensions/gsd/doctor.ts +12 -1
- package/src/resources/extensions/gsd/exit-command.ts +14 -2
- package/src/resources/extensions/gsd/export.ts +8 -15
- package/src/resources/extensions/gsd/extension-manifest.json +2 -2
- package/src/resources/extensions/gsd/files.ts +29 -12
- package/src/resources/extensions/gsd/forensics.ts +9 -3
- package/src/resources/extensions/gsd/git-service.ts +5 -4
- package/src/resources/extensions/gsd/gsd-db.ts +37 -8
- package/src/resources/extensions/gsd/guided-flow-queue.ts +1 -1
- package/src/resources/extensions/gsd/guided-flow.ts +7 -3
- package/src/resources/extensions/gsd/journal.ts +134 -0
- package/src/resources/extensions/gsd/md-importer.ts +6 -0
- package/src/resources/extensions/gsd/milestone-ids.ts +1 -1
- package/src/resources/extensions/gsd/native-git-bridge.ts +2 -2
- package/src/resources/extensions/gsd/post-unit-hooks.ts +24 -462
- package/src/resources/extensions/gsd/preferences-types.ts +3 -0
- package/src/resources/extensions/gsd/preferences.ts +1 -0
- package/src/resources/extensions/gsd/prompt-loader.ts +35 -4
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +11 -10
- package/src/resources/extensions/gsd/prompts/discuss-headless.md +2 -2
- package/src/resources/extensions/gsd/prompts/discuss.md +1 -1
- package/src/resources/extensions/gsd/prompts/queue.md +1 -1
- package/src/resources/extensions/gsd/repo-identity.ts +47 -2
- package/src/resources/extensions/gsd/rule-registry.ts +599 -0
- package/src/resources/extensions/gsd/rule-types.ts +68 -0
- package/src/resources/extensions/gsd/service-tier.ts +171 -0
- package/src/resources/extensions/gsd/structured-data-formatter.ts +3 -1
- package/src/resources/extensions/gsd/templates/decisions.md +2 -2
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +3 -2
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +85 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +202 -0
- package/src/resources/extensions/gsd/tests/context-store.test.ts +10 -5
- package/src/resources/extensions/gsd/tests/db-writer.test.ts +10 -0
- package/src/resources/extensions/gsd/tests/doctor-completion-deferral.test.ts +15 -10
- package/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +5 -4
- package/src/resources/extensions/gsd/tests/doctor-roadmap-summary-atomicity.test.ts +167 -0
- package/src/resources/extensions/gsd/tests/doctor-task-done-missing-summary-slice-loop.test.ts +174 -0
- package/src/resources/extensions/gsd/tests/exit-command.test.ts +55 -0
- package/src/resources/extensions/gsd/tests/gsd-db.test.ts +8 -1
- package/src/resources/extensions/gsd/tests/gsd-tools.test.ts +7 -7
- package/src/resources/extensions/gsd/tests/journal-integration.test.ts +513 -0
- package/src/resources/extensions/gsd/tests/journal-query-tool.test.ts +147 -0
- package/src/resources/extensions/gsd/tests/journal.test.ts +386 -0
- package/src/resources/extensions/gsd/tests/md-importer.test.ts +31 -1
- package/src/resources/extensions/gsd/tests/memory-store.test.ts +2 -2
- package/src/resources/extensions/gsd/tests/milestone-id-reservation.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/parsers.test.ts +110 -0
- package/src/resources/extensions/gsd/tests/preferences.test.ts +47 -25
- package/src/resources/extensions/gsd/tests/prompt-db.test.ts +3 -1
- package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +61 -1
- package/src/resources/extensions/gsd/tests/routing-history.test.ts +11 -22
- package/src/resources/extensions/gsd/tests/rule-registry.test.ts +413 -0
- package/src/resources/extensions/gsd/tests/service-tier.test.ts +98 -0
- package/src/resources/extensions/gsd/tests/skill-lifecycle.test.ts +2 -2
- package/src/resources/extensions/gsd/tests/stalled-tool-recovery.test.ts +102 -0
- package/src/resources/extensions/gsd/tests/structured-data-formatter.test.ts +4 -3
- package/src/resources/extensions/gsd/tests/tool-naming.test.ts +117 -0
- package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +6 -1
- package/src/resources/extensions/gsd/tests/windows-path-normalization.test.ts +99 -0
- package/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/worktree-db.test.ts +4 -0
- package/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts +178 -0
- package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +78 -3
- package/src/resources/extensions/gsd/tests/worktree-symlink-removal.test.ts +140 -0
- package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +74 -0
- package/src/resources/extensions/gsd/types.ts +3 -0
- package/src/resources/extensions/gsd/workflow-templates.ts +12 -1
- package/src/resources/extensions/gsd/worktree-manager.ts +21 -6
- package/src/resources/extensions/gsd/worktree-resolver.ts +30 -9
- package/src/resources/extensions/subagent/index.ts +7 -3
- package/src/resources/extensions/voice/index.ts +4 -4
- package/dist/web/standalone/.next/static/chunks/4024.279c423e4661ece1.js +0 -9
- /package/dist/web/standalone/.next/static/{SwbKZ7JPNFlEmU4f8pKEv → JBSIr4fSfHXs5g5x2ZBSC}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{SwbKZ7JPNFlEmU4f8pKEv → JBSIr4fSfHXs5g5x2ZBSC}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// GSD Extension — Rule Registry Tests
|
|
2
|
+
//
|
|
3
|
+
// Tests the RuleRegistry class, UnifiedRule types, singleton accessors,
|
|
4
|
+
// and evaluation methods using mock rules.
|
|
5
|
+
|
|
6
|
+
import { test, describe, beforeEach } from "node:test";
|
|
7
|
+
import { createTestContext } from "./test-helpers.ts";
|
|
8
|
+
import {
|
|
9
|
+
RuleRegistry,
|
|
10
|
+
getRegistry,
|
|
11
|
+
setRegistry,
|
|
12
|
+
initRegistry,
|
|
13
|
+
resetRegistry,
|
|
14
|
+
convertDispatchRules,
|
|
15
|
+
getOrCreateRegistry,
|
|
16
|
+
} from "../rule-registry.ts";
|
|
17
|
+
import type { UnifiedRule } from "../rule-types.ts";
|
|
18
|
+
import type { DispatchAction, DispatchContext } from "../auto-dispatch.ts";
|
|
19
|
+
import { DISPATCH_RULES, getDispatchRuleNames } from "../auto-dispatch.ts";
|
|
20
|
+
import type { GSDState } from "../types.ts";
|
|
21
|
+
|
|
22
|
+
// ─── Mock Rule Factories ──────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function mockDispatchRule(name: string, matchPhase: string): UnifiedRule {
|
|
25
|
+
return {
|
|
26
|
+
name,
|
|
27
|
+
when: "dispatch",
|
|
28
|
+
evaluation: "first-match",
|
|
29
|
+
where: async (ctx: DispatchContext): Promise<DispatchAction | null> => {
|
|
30
|
+
if (ctx.state.phase === matchPhase) {
|
|
31
|
+
return {
|
|
32
|
+
action: "dispatch",
|
|
33
|
+
unitType: `test-${matchPhase}`,
|
|
34
|
+
unitId: "test-id",
|
|
35
|
+
prompt: `Prompt for ${matchPhase}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
},
|
|
40
|
+
then: () => {},
|
|
41
|
+
description: `Mock rule for ${matchPhase}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeContext(phase: string): DispatchContext {
|
|
46
|
+
return {
|
|
47
|
+
basePath: "/tmp/test",
|
|
48
|
+
mid: "M001",
|
|
49
|
+
midTitle: "Test Milestone",
|
|
50
|
+
state: {
|
|
51
|
+
phase: phase as any,
|
|
52
|
+
activeMilestone: { id: "M001", title: "Test" },
|
|
53
|
+
activeSlice: null,
|
|
54
|
+
activeTask: null,
|
|
55
|
+
recentDecisions: [],
|
|
56
|
+
blockers: [],
|
|
57
|
+
nextAction: "",
|
|
58
|
+
registry: [],
|
|
59
|
+
},
|
|
60
|
+
prefs: undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Tests ────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
describe("RuleRegistry", () => {
|
|
67
|
+
const { assertEq, assertTrue } = createTestContext();
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
resetRegistry();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("construct with dispatch rules, listRules returns them", () => {
|
|
74
|
+
const rules: UnifiedRule[] = [
|
|
75
|
+
mockDispatchRule("rule-a", "planning"),
|
|
76
|
+
mockDispatchRule("rule-b", "executing"),
|
|
77
|
+
mockDispatchRule("rule-c", "complete"),
|
|
78
|
+
];
|
|
79
|
+
const registry = new RuleRegistry(rules);
|
|
80
|
+
const listed = registry.listRules();
|
|
81
|
+
|
|
82
|
+
// At minimum, dispatch rules are returned (hook rules depend on prefs)
|
|
83
|
+
const dispatchRules = listed.filter(r => r.when === "dispatch");
|
|
84
|
+
assertEq(dispatchRules.length, 3, "listRules returns 3 dispatch rules");
|
|
85
|
+
assertEq(dispatchRules[0].name, "rule-a", "first rule name is rule-a");
|
|
86
|
+
assertEq(dispatchRules[1].name, "rule-b", "second rule name is rule-b");
|
|
87
|
+
assertEq(dispatchRules[2].name, "rule-c", "third rule name is rule-c");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("listRules returns correct fields on each rule", () => {
|
|
91
|
+
const rules: UnifiedRule[] = [
|
|
92
|
+
mockDispatchRule("check-fields", "planning"),
|
|
93
|
+
];
|
|
94
|
+
const registry = new RuleRegistry(rules);
|
|
95
|
+
const listed = registry.listRules();
|
|
96
|
+
const rule = listed.find(r => r.name === "check-fields")!;
|
|
97
|
+
|
|
98
|
+
assertTrue(rule !== undefined, "rule found by name");
|
|
99
|
+
assertEq(rule.when, "dispatch", "when field is dispatch");
|
|
100
|
+
assertEq(rule.evaluation, "first-match", "evaluation is first-match");
|
|
101
|
+
assertTrue(typeof rule.where === "function", "where is a function");
|
|
102
|
+
assertTrue(typeof rule.then === "function", "then is a function");
|
|
103
|
+
assertEq(rule.description, "Mock rule for planning", "description is set");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("evaluateDispatch returns first matching rule", async () => {
|
|
107
|
+
const rules: UnifiedRule[] = [
|
|
108
|
+
mockDispatchRule("rule-planning", "planning"),
|
|
109
|
+
mockDispatchRule("rule-executing", "executing"),
|
|
110
|
+
mockDispatchRule("rule-complete", "complete"),
|
|
111
|
+
];
|
|
112
|
+
const registry = new RuleRegistry(rules);
|
|
113
|
+
const ctx = makeContext("executing");
|
|
114
|
+
const result = await registry.evaluateDispatch(ctx);
|
|
115
|
+
|
|
116
|
+
assertEq(result.action, "dispatch", "result is a dispatch action");
|
|
117
|
+
if (result.action === "dispatch") {
|
|
118
|
+
assertEq(result.unitType, "test-executing", "matched the executing rule");
|
|
119
|
+
assertEq(result.prompt, "Prompt for executing", "prompt from matched rule");
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("evaluateDispatch returns stop when no rule matches", async () => {
|
|
124
|
+
const rules: UnifiedRule[] = [
|
|
125
|
+
mockDispatchRule("only-planning", "planning"),
|
|
126
|
+
];
|
|
127
|
+
const registry = new RuleRegistry(rules);
|
|
128
|
+
const ctx = makeContext("blocked");
|
|
129
|
+
const result = await registry.evaluateDispatch(ctx);
|
|
130
|
+
|
|
131
|
+
assertEq(result.action, "stop", "result is a stop action");
|
|
132
|
+
if (result.action === "stop") {
|
|
133
|
+
assertTrue(result.reason.includes("blocked"), "stop reason mentions phase");
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("evaluateDispatch works with async where predicate", async () => {
|
|
138
|
+
const asyncRule: UnifiedRule = {
|
|
139
|
+
name: "async-rule",
|
|
140
|
+
when: "dispatch",
|
|
141
|
+
evaluation: "first-match",
|
|
142
|
+
where: async (ctx: DispatchContext): Promise<DispatchAction | null> => {
|
|
143
|
+
// Simulate async work
|
|
144
|
+
await new Promise(resolve => setTimeout(resolve, 1));
|
|
145
|
+
if (ctx.state.phase === "planning") {
|
|
146
|
+
return {
|
|
147
|
+
action: "dispatch",
|
|
148
|
+
unitType: "async-test",
|
|
149
|
+
unitId: "async-id",
|
|
150
|
+
prompt: "Async prompt",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
},
|
|
155
|
+
then: () => {},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const registry = new RuleRegistry([asyncRule]);
|
|
159
|
+
const ctx = makeContext("planning");
|
|
160
|
+
const result = await registry.evaluateDispatch(ctx);
|
|
161
|
+
|
|
162
|
+
assertEq(result.action, "dispatch", "async dispatch resolved");
|
|
163
|
+
if (result.action === "dispatch") {
|
|
164
|
+
assertEq(result.unitType, "async-test", "async rule matched");
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("resetState clears all mutable state", () => {
|
|
169
|
+
const registry = new RuleRegistry([]);
|
|
170
|
+
|
|
171
|
+
// Set up some state
|
|
172
|
+
registry.activeHook = {
|
|
173
|
+
hookName: "test-hook",
|
|
174
|
+
triggerUnitType: "execute-task",
|
|
175
|
+
triggerUnitId: "M001/S01/T01",
|
|
176
|
+
cycle: 2,
|
|
177
|
+
pendingRetry: false,
|
|
178
|
+
};
|
|
179
|
+
registry.hookQueue.push({
|
|
180
|
+
config: { name: "q", after: [], prompt: "p" },
|
|
181
|
+
triggerUnitType: "execute-task",
|
|
182
|
+
triggerUnitId: "M001/S01/T02",
|
|
183
|
+
});
|
|
184
|
+
registry.cycleCounts.set("test/key", 3);
|
|
185
|
+
registry.retryPending = true;
|
|
186
|
+
registry.retryTrigger = { unitType: "execute-task", unitId: "M001/S01/T01", retryArtifact: "RETRY" };
|
|
187
|
+
|
|
188
|
+
// Reset
|
|
189
|
+
registry.resetState();
|
|
190
|
+
|
|
191
|
+
assertEq(registry.getActiveHook(), null, "activeHook cleared");
|
|
192
|
+
assertEq(registry.hookQueue.length, 0, "hookQueue cleared");
|
|
193
|
+
assertEq(registry.cycleCounts.size, 0, "cycleCounts cleared");
|
|
194
|
+
assertEq(registry.isRetryPending(), false, "retryPending cleared");
|
|
195
|
+
assertEq(registry.consumeRetryTrigger(), null, "retryTrigger cleared");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("singleton getRegistry throws when not initialized", () => {
|
|
199
|
+
let threw = false;
|
|
200
|
+
try {
|
|
201
|
+
getRegistry();
|
|
202
|
+
} catch (e: any) {
|
|
203
|
+
threw = true;
|
|
204
|
+
assertTrue(e.message.includes("not initialized"), "error mentions not initialized");
|
|
205
|
+
}
|
|
206
|
+
assertTrue(threw, "getRegistry threw");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("setRegistry / getRegistry round-trips", () => {
|
|
210
|
+
const registry = new RuleRegistry([mockDispatchRule("singleton-test", "planning")]);
|
|
211
|
+
setRegistry(registry);
|
|
212
|
+
|
|
213
|
+
const retrieved = getRegistry();
|
|
214
|
+
assertEq(retrieved, registry, "getRegistry returns the same instance");
|
|
215
|
+
|
|
216
|
+
const listed = retrieved.listRules().filter(r => r.when === "dispatch");
|
|
217
|
+
assertEq(listed.length, 1, "singleton has 1 dispatch rule");
|
|
218
|
+
assertEq(listed[0].name, "singleton-test", "rule name matches");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("initRegistry creates and sets singleton", () => {
|
|
222
|
+
const rules = [mockDispatchRule("init-test", "executing")];
|
|
223
|
+
const registry = initRegistry(rules);
|
|
224
|
+
|
|
225
|
+
assertEq(getRegistry(), registry, "initRegistry sets the singleton");
|
|
226
|
+
const listed = getRegistry().listRules().filter(r => r.when === "dispatch");
|
|
227
|
+
assertEq(listed.length, 1, "singleton has the rule");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("evaluateDispatch respects rule order (first match wins)", async () => {
|
|
231
|
+
// Both rules match "planning" but rule-first should win
|
|
232
|
+
const ruleFirst: UnifiedRule = {
|
|
233
|
+
name: "rule-first",
|
|
234
|
+
when: "dispatch",
|
|
235
|
+
evaluation: "first-match",
|
|
236
|
+
where: async (ctx: DispatchContext) => {
|
|
237
|
+
if (ctx.state.phase === "planning") {
|
|
238
|
+
return { action: "dispatch" as const, unitType: "first-wins", unitId: "id", prompt: "first" };
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
},
|
|
242
|
+
then: () => {},
|
|
243
|
+
};
|
|
244
|
+
const ruleSecond: UnifiedRule = {
|
|
245
|
+
name: "rule-second",
|
|
246
|
+
when: "dispatch",
|
|
247
|
+
evaluation: "first-match",
|
|
248
|
+
where: async (ctx: DispatchContext) => {
|
|
249
|
+
if (ctx.state.phase === "planning") {
|
|
250
|
+
return { action: "dispatch" as const, unitType: "second-loses", unitId: "id", prompt: "second" };
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
},
|
|
254
|
+
then: () => {},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const registry = new RuleRegistry([ruleFirst, ruleSecond]);
|
|
258
|
+
const ctx = makeContext("planning");
|
|
259
|
+
const result = await registry.evaluateDispatch(ctx);
|
|
260
|
+
|
|
261
|
+
assertEq(result.action, "dispatch", "dispatch action returned");
|
|
262
|
+
if (result.action === "dispatch") {
|
|
263
|
+
assertEq(result.unitType, "first-wins", "first rule won over second");
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ── Dispatch rule conversion tests ─────────────────────────────────
|
|
268
|
+
|
|
269
|
+
test("convertDispatchRules produces correct count of UnifiedRule objects", () => {
|
|
270
|
+
const converted = convertDispatchRules(DISPATCH_RULES);
|
|
271
|
+
assertEq(converted.length, DISPATCH_RULES.length, `convertDispatchRules produces ${DISPATCH_RULES.length} rules`);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("each converted rule has correct when, evaluation, and original name", () => {
|
|
275
|
+
const converted = convertDispatchRules(DISPATCH_RULES);
|
|
276
|
+
for (let i = 0; i < converted.length; i++) {
|
|
277
|
+
const rule = converted[i];
|
|
278
|
+
assertEq(rule.when, "dispatch", `rule ${i} has when:"dispatch"`);
|
|
279
|
+
assertEq(rule.evaluation, "first-match", `rule ${i} has evaluation:"first-match"`);
|
|
280
|
+
assertEq(rule.name, DISPATCH_RULES[i].name, `rule ${i} preserves name "${DISPATCH_RULES[i].name}"`);
|
|
281
|
+
assertTrue(typeof rule.where === "function", `rule ${i} has a where function`);
|
|
282
|
+
assertTrue(typeof rule.then === "function", `rule ${i} has a then function`);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("listRules after construction with real dispatch rules returns correct count", () => {
|
|
287
|
+
const converted = convertDispatchRules(DISPATCH_RULES);
|
|
288
|
+
const registry = new RuleRegistry(converted);
|
|
289
|
+
const listed = registry.listRules().filter(r => r.when === "dispatch");
|
|
290
|
+
assertEq(listed.length, DISPATCH_RULES.length, `listRules returns ${DISPATCH_RULES.length} dispatch rules`);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("rule names from listRules match getDispatchRuleNames in exact order", () => {
|
|
294
|
+
const converted = convertDispatchRules(DISPATCH_RULES);
|
|
295
|
+
const registry = new RuleRegistry(converted);
|
|
296
|
+
const listedNames = registry.listRules()
|
|
297
|
+
.filter(r => r.when === "dispatch")
|
|
298
|
+
.map(r => r.name);
|
|
299
|
+
const originalNames = getDispatchRuleNames();
|
|
300
|
+
|
|
301
|
+
assertEq(listedNames.length, originalNames.length, "same number of names");
|
|
302
|
+
for (let i = 0; i < originalNames.length; i++) {
|
|
303
|
+
assertEq(listedNames[i], originalNames[i], `name at index ${i} matches: "${originalNames[i]}"`);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// ── getOrCreateRegistry (lazy init for facades) ────────────────────
|
|
308
|
+
|
|
309
|
+
test("getOrCreateRegistry lazily creates a registry with empty dispatch rules", () => {
|
|
310
|
+
// After resetRegistry(), getRegistry() would throw. getOrCreateRegistry() should not.
|
|
311
|
+
const registry = getOrCreateRegistry();
|
|
312
|
+
assertTrue(registry instanceof RuleRegistry, "returns a RuleRegistry instance");
|
|
313
|
+
const dispatchRules = registry.listRules().filter(r => r.when === "dispatch");
|
|
314
|
+
assertEq(dispatchRules.length, 0, "lazily-created registry has 0 dispatch rules");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("getOrCreateRegistry returns existing registry when initialized", () => {
|
|
318
|
+
const rules = [mockDispatchRule("explicit-init", "planning")];
|
|
319
|
+
const explicit = initRegistry(rules);
|
|
320
|
+
const lazy = getOrCreateRegistry();
|
|
321
|
+
assertEq(lazy, explicit, "getOrCreateRegistry returns the same singleton as initRegistry");
|
|
322
|
+
const dispatchRules = lazy.listRules().filter(r => r.when === "dispatch");
|
|
323
|
+
assertEq(dispatchRules.length, 1, "singleton has the explicitly initialized dispatch rule");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ── Hook-derived rules in listRules ────────────────────────────────
|
|
327
|
+
|
|
328
|
+
test("listRules returns only dispatch rules when no hooks are configured", () => {
|
|
329
|
+
const converted = convertDispatchRules(DISPATCH_RULES);
|
|
330
|
+
const registry = new RuleRegistry(converted);
|
|
331
|
+
const allRules = registry.listRules();
|
|
332
|
+
const postUnitRules = allRules.filter(r => r.when === "post-unit");
|
|
333
|
+
const preDispatchRules = allRules.filter(r => r.when === "pre-dispatch");
|
|
334
|
+
|
|
335
|
+
// No preferences file = no hooks
|
|
336
|
+
assertEq(postUnitRules.length, 0, "no post-unit rules when no hooks configured");
|
|
337
|
+
assertEq(preDispatchRules.length, 0, "no pre-dispatch rules when no hooks configured");
|
|
338
|
+
assertEq(allRules.length, DISPATCH_RULES.length, "total rules equals dispatch rules only");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("listRules dispatch rules appear first, hooks after", () => {
|
|
342
|
+
const converted = convertDispatchRules(DISPATCH_RULES);
|
|
343
|
+
const registry = new RuleRegistry(converted);
|
|
344
|
+
const allRules = registry.listRules();
|
|
345
|
+
|
|
346
|
+
// Verify dispatch rules come first (indices 0..N-1)
|
|
347
|
+
for (let i = 0; i < converted.length; i++) {
|
|
348
|
+
assertEq(allRules[i].when, "dispatch", `rule at index ${i} is a dispatch rule`);
|
|
349
|
+
assertEq(allRules[i].name, converted[i].name, `dispatch rule at index ${i} has correct name`);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// ── Facade delegation (post-unit-hooks.ts imports work through registry) ──
|
|
354
|
+
|
|
355
|
+
test("evaluatePostUnit returns null for hook-on-hook prevention", () => {
|
|
356
|
+
const registry = new RuleRegistry([]);
|
|
357
|
+
const result = registry.evaluatePostUnit("hook/code-review", "M001/S01/T01", "/tmp/test");
|
|
358
|
+
assertEq(result, null, "hook units don't trigger other hooks");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("evaluatePostUnit returns null for triage-captures", () => {
|
|
362
|
+
const registry = new RuleRegistry([]);
|
|
363
|
+
const result = registry.evaluatePostUnit("triage-captures", "M001/S01/T01", "/tmp/test");
|
|
364
|
+
assertEq(result, null, "triage-captures skipped");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("evaluatePostUnit returns null for quick-task", () => {
|
|
368
|
+
const registry = new RuleRegistry([]);
|
|
369
|
+
const result = registry.evaluatePostUnit("quick-task", "M001/S01/T01", "/tmp/test");
|
|
370
|
+
assertEq(result, null, "quick-task skipped");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("evaluatePreDispatch bypasses hook units", () => {
|
|
374
|
+
const registry = new RuleRegistry([]);
|
|
375
|
+
const result = registry.evaluatePreDispatch("hook/review", "M001/S01/T01", "prompt", "/tmp/test");
|
|
376
|
+
assertEq(result.action, "proceed", "hook units always proceed");
|
|
377
|
+
assertEq(result.prompt, "prompt", "prompt unchanged");
|
|
378
|
+
assertEq(result.firedHooks.length, 0, "no hooks fired");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("evaluatePreDispatch proceeds with empty hooks", () => {
|
|
382
|
+
const registry = new RuleRegistry([]);
|
|
383
|
+
const result = registry.evaluatePreDispatch("execute-task", "M001/S01/T01", "original prompt", "/tmp/test");
|
|
384
|
+
assertEq(result.action, "proceed", "proceeds when no hooks");
|
|
385
|
+
assertEq(result.prompt, "original prompt", "prompt unchanged");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ── matchedRule provenance (S02 journal support) ───────────────────
|
|
389
|
+
|
|
390
|
+
test("evaluateDispatch result includes matchedRule on dispatch match", async () => {
|
|
391
|
+
const rules: UnifiedRule[] = [
|
|
392
|
+
mockDispatchRule("my-planning-rule", "planning"),
|
|
393
|
+
];
|
|
394
|
+
const registry = new RuleRegistry(rules);
|
|
395
|
+
const ctx = makeContext("planning");
|
|
396
|
+
const result = await registry.evaluateDispatch(ctx);
|
|
397
|
+
|
|
398
|
+
assertEq(result.action, "dispatch", "result is a dispatch action");
|
|
399
|
+
assertEq(result.matchedRule, "my-planning-rule", "matchedRule is the rule name");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("evaluateDispatch result includes matchedRule '<no-match>' on fallback stop", async () => {
|
|
403
|
+
const rules: UnifiedRule[] = [
|
|
404
|
+
mockDispatchRule("only-planning", "planning"),
|
|
405
|
+
];
|
|
406
|
+
const registry = new RuleRegistry(rules);
|
|
407
|
+
const ctx = makeContext("some-unknown-phase");
|
|
408
|
+
const result = await registry.evaluateDispatch(ctx);
|
|
409
|
+
|
|
410
|
+
assertEq(result.action, "stop", "result is a stop action");
|
|
411
|
+
assertEq(result.matchedRule, "<no-match>", "matchedRule is '<no-match>' on fallback");
|
|
412
|
+
});
|
|
413
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import test, { describe } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
supportsServiceTier,
|
|
6
|
+
formatServiceTierStatus,
|
|
7
|
+
resolveServiceTierIcon,
|
|
8
|
+
type ServiceTierSetting,
|
|
9
|
+
} from "../service-tier.ts";
|
|
10
|
+
|
|
11
|
+
// ─── supportsServiceTier ─────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe("supportsServiceTier", () => {
|
|
14
|
+
test("returns true for gpt-5.4", () => {
|
|
15
|
+
assert.equal(supportsServiceTier("gpt-5.4"), true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("returns true for gpt-5.4-pro", () => {
|
|
19
|
+
assert.equal(supportsServiceTier("gpt-5.4-pro"), true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("returns true for gpt-5.4-mini", () => {
|
|
23
|
+
assert.equal(supportsServiceTier("gpt-5.4-mini"), true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("returns true for openai/gpt-5.4 (provider-prefixed)", () => {
|
|
27
|
+
assert.equal(supportsServiceTier("openai/gpt-5.4"), true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("returns false for claude-opus-4-6", () => {
|
|
31
|
+
assert.equal(supportsServiceTier("claude-opus-4-6"), false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("returns false for gemini-2.5-pro", () => {
|
|
35
|
+
assert.equal(supportsServiceTier("gemini-2.5-pro"), false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("returns false for gpt-4o", () => {
|
|
39
|
+
assert.equal(supportsServiceTier("gpt-4o"), false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("returns false for empty string", () => {
|
|
43
|
+
assert.equal(supportsServiceTier(""), false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─── formatServiceTierStatus ─────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe("formatServiceTierStatus", () => {
|
|
50
|
+
test("shows disabled when service_tier is undefined", () => {
|
|
51
|
+
const output = formatServiceTierStatus(undefined);
|
|
52
|
+
assert.ok(output.includes("disabled"), `Expected 'disabled' in: ${output}`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("shows priority when set to priority", () => {
|
|
56
|
+
const output = formatServiceTierStatus("priority");
|
|
57
|
+
assert.ok(output.includes("priority"), `Expected 'priority' in: ${output}`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("shows flex when set to flex", () => {
|
|
61
|
+
const output = formatServiceTierStatus("flex");
|
|
62
|
+
assert.ok(output.includes("flex"), `Expected 'flex' in: ${output}`);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ─── resolveServiceTierIcon ──────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe("resolveServiceTierIcon", () => {
|
|
69
|
+
test("returns lightning bolt for priority tier on supported model", () => {
|
|
70
|
+
const icon = resolveServiceTierIcon("priority", "gpt-5.4");
|
|
71
|
+
assert.equal(icon, "⚡");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns money icon for flex tier on supported model", () => {
|
|
75
|
+
const icon = resolveServiceTierIcon("flex", "gpt-5.4");
|
|
76
|
+
assert.equal(icon, "💰");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("returns empty string when tier is set but model does not support it", () => {
|
|
80
|
+
const icon = resolveServiceTierIcon("priority", "claude-opus-4-6");
|
|
81
|
+
assert.equal(icon, "");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("returns empty string when tier is undefined", () => {
|
|
85
|
+
const icon = resolveServiceTierIcon(undefined, "gpt-5.4");
|
|
86
|
+
assert.equal(icon, "");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("returns empty string when both tier and model are unsupported", () => {
|
|
90
|
+
const icon = resolveServiceTierIcon(undefined, "claude-opus-4-6");
|
|
91
|
+
assert.equal(icon, "");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("returns empty string when model is empty", () => {
|
|
95
|
+
const icon = resolveServiceTierIcon("priority", "");
|
|
96
|
+
assert.equal(icon, "");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Tests the pure functions — no file I/O, no extension context.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { describe, it
|
|
6
|
+
import { describe, it } from "node:test";
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import type { UnitMetrics } from "../metrics.js";
|
|
9
9
|
|
|
@@ -72,7 +72,7 @@ describe("skill-health", () => {
|
|
|
72
72
|
|
|
73
73
|
// With no metrics file, should return empty
|
|
74
74
|
const result = computeStaleAvoidList("/nonexistent/path", ["some-skill"]);
|
|
75
|
-
assert.
|
|
75
|
+
assert.deepEqual(result, []);
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
78
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for #1855: Stalled tool detection crashes with
|
|
3
|
+
* "The path argument must be of type string. Received undefined"
|
|
4
|
+
*
|
|
5
|
+
* When a tool stalls in-flight for 10+ minutes, the idle watchdog fires
|
|
6
|
+
* recoverTimedOutUnit(). In auto/phases.ts, buildRecoveryContext was
|
|
7
|
+
* returning an empty object `{}`, so basePath was undefined. The recovery
|
|
8
|
+
* code passed undefined to readUnitRuntimeRecord → runtimePath → join(),
|
|
9
|
+
* which throws a TypeError. The session is permanently frozen because the
|
|
10
|
+
* error propagates into the idle watchdog catch handler but the unit
|
|
11
|
+
* promise is never resolved.
|
|
12
|
+
*
|
|
13
|
+
* This test calls recoverTimedOutUnit with an empty RecoveryContext (the
|
|
14
|
+
* bug) and verifies it crashes, then calls it with a valid RecoveryContext
|
|
15
|
+
* (the fix) and verifies it does not crash.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { recoverTimedOutUnit, type RecoveryContext } from "../auto-timeout-recovery.ts";
|
|
22
|
+
import { createTestContext } from './test-helpers.ts';
|
|
23
|
+
|
|
24
|
+
const { assertTrue, report } = createTestContext();
|
|
25
|
+
|
|
26
|
+
// Minimal mock for ExtensionContext — only the fields recoverTimedOutUnit touches.
|
|
27
|
+
function makeMockCtx() {
|
|
28
|
+
return {
|
|
29
|
+
ui: {
|
|
30
|
+
notify: () => {},
|
|
31
|
+
},
|
|
32
|
+
} as any;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Minimal mock for ExtensionAPI — only sendMessage is called during recovery.
|
|
36
|
+
function makeMockPi() {
|
|
37
|
+
return {
|
|
38
|
+
sendMessage: () => {},
|
|
39
|
+
} as any;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ═══ #1855: empty RecoveryContext (basePath undefined) crashes ════════════════
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
console.log("\n=== #1855: recoverTimedOutUnit crashes when basePath is undefined ===");
|
|
46
|
+
const ctx = makeMockCtx();
|
|
47
|
+
const pi = makeMockPi();
|
|
48
|
+
|
|
49
|
+
// Simulate the bug: buildRecoveryContext returns {} (empty object).
|
|
50
|
+
// basePath is undefined, which causes join(undefined, ".gsd") to throw.
|
|
51
|
+
const emptyRctx = {} as RecoveryContext;
|
|
52
|
+
|
|
53
|
+
let crashed = false;
|
|
54
|
+
try {
|
|
55
|
+
await recoverTimedOutUnit(ctx, pi, "execute-task", "M001/S01/T01", "idle", emptyRctx);
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
crashed = true;
|
|
58
|
+
assertTrue(
|
|
59
|
+
err.message.includes("path") || err.message.includes("string") || err.code === "ERR_INVALID_ARG_TYPE",
|
|
60
|
+
`should crash with path/type error, got: ${err.message}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
assertTrue(crashed, "should crash when basePath is undefined (reproduces #1855)");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ═══ #1855: valid RecoveryContext does not crash ═════════════════════════════
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
console.log("\n=== #1855: recoverTimedOutUnit succeeds with valid RecoveryContext ===");
|
|
70
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-stalled-tool-test-"));
|
|
71
|
+
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
|
|
72
|
+
mkdirSync(join(base, ".gsd", "runtime", "units"), { recursive: true });
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const ctx = makeMockCtx();
|
|
76
|
+
const pi = makeMockPi();
|
|
77
|
+
|
|
78
|
+
const validRctx: RecoveryContext = {
|
|
79
|
+
basePath: base,
|
|
80
|
+
verbose: false,
|
|
81
|
+
currentUnitStartedAt: Date.now(),
|
|
82
|
+
unitRecoveryCount: new Map(),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
let crashed = false;
|
|
86
|
+
let result: string | undefined;
|
|
87
|
+
try {
|
|
88
|
+
result = await recoverTimedOutUnit(ctx, pi, "execute-task", "M001/S01/T01", "idle", validRctx);
|
|
89
|
+
} catch (err: any) {
|
|
90
|
+
crashed = true;
|
|
91
|
+
console.error(` Unexpected crash: ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
assertTrue(!crashed, "should not crash with valid basePath");
|
|
94
|
+
// With no runtime record on disk and recoveryAttempts=0, the function
|
|
95
|
+
// should attempt steering recovery (sendMessage) and return "recovered".
|
|
96
|
+
assertTrue(result === "recovered", `should return 'recovered', got '${result}'`);
|
|
97
|
+
} finally {
|
|
98
|
+
rmSync(base, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
report();
|
|
@@ -86,16 +86,17 @@ describe("structured-data-formatter: formatDecisionCompact", () => {
|
|
|
86
86
|
const result = formatDecisionCompact(sampleDecision);
|
|
87
87
|
assert.equal(
|
|
88
88
|
result,
|
|
89
|
-
"D001 | M001/S01 | architecture | Use SQLite for storage | WAL mode, single-writer | Built-in, no external deps | yes",
|
|
89
|
+
"D001 | M001/S01 | architecture | Use SQLite for storage | WAL mode, single-writer | Built-in, no external deps | yes | agent",
|
|
90
90
|
);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
it("includes all fields in the correct order", () => {
|
|
94
94
|
const result = formatDecisionCompact(sampleDecision);
|
|
95
95
|
const parts = result.split(" | ");
|
|
96
|
-
assert.equal(parts.length,
|
|
96
|
+
assert.equal(parts.length, 8);
|
|
97
97
|
assert.equal(parts[0], "D001");
|
|
98
98
|
assert.equal(parts[6], "yes");
|
|
99
|
+
assert.equal(parts[7], "agent");
|
|
99
100
|
});
|
|
100
101
|
});
|
|
101
102
|
|
|
@@ -107,7 +108,7 @@ describe("structured-data-formatter: formatDecisionsCompact", () => {
|
|
|
107
108
|
it("includes Fields header line", () => {
|
|
108
109
|
const result = formatDecisionsCompact([sampleDecision]);
|
|
109
110
|
assert.ok(result.startsWith("# Decisions (compact)"));
|
|
110
|
-
assert.ok(result.includes("Fields: id | when | scope | decision | choice | rationale | revisable"));
|
|
111
|
+
assert.ok(result.includes("Fields: id | when | scope | decision | choice | rationale | revisable | made_by"));
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
it("formats multiple decisions on separate lines", () => {
|