@cad0p/pi-steering 0.1.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/CHANGELOG.md +36 -0
- package/LICENSE +29 -0
- package/README.md +920 -0
- package/dist/__test-helpers__.d.ts +95 -0
- package/dist/__test-helpers__.d.ts.map +1 -0
- package/dist/__test-helpers__.js +174 -0
- package/dist/__test-helpers__.js.map +1 -0
- package/dist/bin/pi-steering.d.ts +9 -0
- package/dist/bin/pi-steering.d.ts.map +1 -0
- package/dist/bin/pi-steering.js +506 -0
- package/dist/bin/pi-steering.js.map +1 -0
- package/dist/compat.d.ts +50 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +180 -0
- package/dist/compat.js.map +1 -0
- package/dist/defaults.d.ts +61 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +106 -0
- package/dist/defaults.js.map +1 -0
- package/dist/define-config.d.ts +235 -0
- package/dist/define-config.d.ts.map +1 -0
- package/dist/define-config.js +118 -0
- package/dist/define-config.js.map +1 -0
- package/dist/define-predicate.d.ts +49 -0
- package/dist/define-predicate.d.ts.map +1 -0
- package/dist/define-predicate.js +35 -0
- package/dist/define-predicate.js.map +1 -0
- package/dist/evaluator-internals/context.d.ts +186 -0
- package/dist/evaluator-internals/context.d.ts.map +1 -0
- package/dist/evaluator-internals/context.js +219 -0
- package/dist/evaluator-internals/context.js.map +1 -0
- package/dist/evaluator-internals/override.d.ts +19 -0
- package/dist/evaluator-internals/override.d.ts.map +1 -0
- package/dist/evaluator-internals/override.js +79 -0
- package/dist/evaluator-internals/override.js.map +1 -0
- package/dist/evaluator-internals/predicates.d.ts +152 -0
- package/dist/evaluator-internals/predicates.d.ts.map +1 -0
- package/dist/evaluator-internals/predicates.js +849 -0
- package/dist/evaluator-internals/predicates.js.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.js +101 -0
- package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
- package/dist/evaluator.d.ts +101 -0
- package/dist/evaluator.d.ts.map +1 -0
- package/dist/evaluator.js +714 -0
- package/dist/evaluator.js.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
- package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/drop-unused-observers.d.ts +43 -0
- package/dist/internal/drop-unused-observers.d.ts.map +1 -0
- package/dist/internal/drop-unused-observers.js +50 -0
- package/dist/internal/drop-unused-observers.js.map +1 -0
- package/dist/internal/finalize-plugin-state.d.ts +22 -0
- package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
- package/dist/internal/finalize-plugin-state.js +14 -0
- package/dist/internal/finalize-plugin-state.js.map +1 -0
- package/dist/internal/merge-observers.d.ts +30 -0
- package/dist/internal/merge-observers.d.ts.map +1 -0
- package/dist/internal/merge-observers.js +27 -0
- package/dist/internal/merge-observers.js.map +1 -0
- package/dist/internal/pattern-utils.d.ts +33 -0
- package/dist/internal/pattern-utils.d.ts.map +1 -0
- package/dist/internal/pattern-utils.js +20 -0
- package/dist/internal/pattern-utils.js.map +1 -0
- package/dist/internal/ref-text.d.ts +11 -0
- package/dist/internal/ref-text.d.ts.map +1 -0
- package/dist/internal/ref-text.js +15 -0
- package/dist/internal/ref-text.js.map +1 -0
- package/dist/internal/session-runtime.d.ts +44 -0
- package/dist/internal/session-runtime.d.ts.map +1 -0
- package/dist/internal/session-runtime.js +143 -0
- package/dist/internal/session-runtime.js.map +1 -0
- package/dist/internal/watch-matcher.d.ts +56 -0
- package/dist/internal/watch-matcher.d.ts.map +1 -0
- package/dist/internal/watch-matcher.js +179 -0
- package/dist/internal/watch-matcher.js.map +1 -0
- package/dist/loader.d.ts +97 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +527 -0
- package/dist/loader.js.map +1 -0
- package/dist/observer-dispatcher.d.ts +69 -0
- package/dist/observer-dispatcher.d.ts.map +1 -0
- package/dist/observer-dispatcher.js +184 -0
- package/dist/observer-dispatcher.js.map +1 -0
- package/dist/plugin-merger.d.ts +175 -0
- package/dist/plugin-merger.d.ts.map +1 -0
- package/dist/plugin-merger.js +450 -0
- package/dist/plugin-merger.js.map +1 -0
- package/dist/plugins/git/branch-tracker.d.ts +85 -0
- package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
- package/dist/plugins/git/branch-tracker.js +181 -0
- package/dist/plugins/git/branch-tracker.js.map +1 -0
- package/dist/plugins/git/cwd-extensions.d.ts +10 -0
- package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
- package/dist/plugins/git/cwd-extensions.js +155 -0
- package/dist/plugins/git/cwd-extensions.js.map +1 -0
- package/dist/plugins/git/git-ops.d.ts +89 -0
- package/dist/plugins/git/git-ops.d.ts.map +1 -0
- package/dist/plugins/git/git-ops.js +107 -0
- package/dist/plugins/git/git-ops.js.map +1 -0
- package/dist/plugins/git/index.d.ts +225 -0
- package/dist/plugins/git/index.d.ts.map +1 -0
- package/dist/plugins/git/index.js +52 -0
- package/dist/plugins/git/index.js.map +1 -0
- package/dist/plugins/git/predicates.d.ts +312 -0
- package/dist/plugins/git/predicates.d.ts.map +1 -0
- package/dist/plugins/git/predicates.js +580 -0
- package/dist/plugins/git/predicates.js.map +1 -0
- package/dist/plugins/git/rules.d.ts +209 -0
- package/dist/plugins/git/rules.d.ts.map +1 -0
- package/dist/plugins/git/rules.js +267 -0
- package/dist/plugins/git/rules.js.map +1 -0
- package/dist/schema.d.ts +1799 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +4 -0
- package/dist/schema.js.map +1 -0
- package/dist/testing/index.d.ts +621 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +747 -0
- package/dist/testing/index.js.map +1 -0
- package/examples/README.md +83 -0
- package/examples/combined-git-discipline/README.md +52 -0
- package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
- package/examples/combined-git-discipline/package.json +18 -0
- package/examples/combined-git-discipline/steering.json +27 -0
- package/examples/combined-git-discipline/steering.test.ts +42 -0
- package/examples/combined-git-discipline/steering.ts +64 -0
- package/examples/combined-git-discipline/tsconfig.json +7 -0
- package/examples/draft-prs-only/README.md +42 -0
- package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
- package/examples/draft-prs-only/package.json +18 -0
- package/examples/draft-prs-only/steering.json +12 -0
- package/examples/draft-prs-only/steering.test.ts +48 -0
- package/examples/draft-prs-only/steering.ts +36 -0
- package/examples/draft-prs-only/tsconfig.json +7 -0
- package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
- package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
- package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
- package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
- package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
- package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
- package/examples/force-push-strict/README.md +75 -0
- package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
- package/examples/force-push-strict/package.json +18 -0
- package/examples/force-push-strict/steering.json +12 -0
- package/examples/force-push-strict/steering.test.ts +51 -0
- package/examples/force-push-strict/steering.ts +54 -0
- package/examples/force-push-strict/tsconfig.json +7 -0
- package/examples/no-amend/README.md +52 -0
- package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
- package/examples/no-amend/package.json +18 -0
- package/examples/no-amend/steering.cwd-scoped.json +12 -0
- package/examples/no-amend/steering.json +11 -0
- package/examples/no-amend/steering.test.ts +49 -0
- package/examples/no-amend/steering.ts +38 -0
- package/examples/no-amend/tsconfig.json +7 -0
- package/examples/work-item-plugin/README.md +103 -0
- package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
- package/examples/work-item-plugin/package.json +19 -0
- package/examples/work-item-plugin/src/index.test.ts +168 -0
- package/examples/work-item-plugin/src/index.ts +122 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
- package/examples/work-item-plugin/tsconfig.json +8 -0
- package/package.json +88 -0
- package/skills/steering-authoring/SKILL.md +102 -0
- package/src/INVARIANTS.md +116 -0
- package/src/__test-helpers__.ts +243 -0
- package/src/bin/pi-steering.test.ts +920 -0
- package/src/bin/pi-steering.ts +584 -0
- package/src/compat.test.ts +431 -0
- package/src/compat.ts +282 -0
- package/src/defaults.test.ts +585 -0
- package/src/defaults.ts +148 -0
- package/src/define-config.test.ts +888 -0
- package/src/define-config.ts +375 -0
- package/src/define-predicate.test.ts +70 -0
- package/src/define-predicate.ts +57 -0
- package/src/evaluator-internals/context.ts +290 -0
- package/src/evaluator-internals/override.test.ts +99 -0
- package/src/evaluator-internals/override.ts +83 -0
- package/src/evaluator-internals/predicates.ts +1037 -0
- package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
- package/src/evaluator-internals/speculative-synthesis.ts +199 -0
- package/src/evaluator.test.ts +6296 -0
- package/src/evaluator.ts +1106 -0
- package/src/factory-time-load.test.ts +630 -0
- package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
- package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
- package/src/index.test.ts +836 -0
- package/src/index.ts +229 -0
- package/src/internal/drop-unused-observers.test.ts +141 -0
- package/src/internal/drop-unused-observers.ts +81 -0
- package/src/internal/finalize-plugin-state.ts +43 -0
- package/src/internal/merge-observers.ts +51 -0
- package/src/internal/pattern-utils.test.ts +52 -0
- package/src/internal/pattern-utils.ts +38 -0
- package/src/internal/ref-text.test.ts +54 -0
- package/src/internal/ref-text.ts +17 -0
- package/src/internal/session-runtime.test.ts +629 -0
- package/src/internal/session-runtime.ts +201 -0
- package/src/internal/watch-matcher.test.ts +326 -0
- package/src/internal/watch-matcher.ts +200 -0
- package/src/jsdoc-propagation.test.ts +277 -0
- package/src/loader.test.ts +1098 -0
- package/src/loader.ts +581 -0
- package/src/not-block-onunknown.test.ts +554 -0
- package/src/observer-dispatcher.test.ts +939 -0
- package/src/observer-dispatcher.ts +290 -0
- package/src/per-predicate-typing.test.ts +361 -0
- package/src/plugin-merger.test.ts +790 -0
- package/src/plugin-merger.ts +598 -0
- package/src/plugins/git/README.md +485 -0
- package/src/plugins/git/branch-tracker.test.ts +281 -0
- package/src/plugins/git/branch-tracker.ts +187 -0
- package/src/plugins/git/cwd-extensions.test.ts +230 -0
- package/src/plugins/git/cwd-extensions.ts +160 -0
- package/src/plugins/git/git-ops.test.ts +397 -0
- package/src/plugins/git/git-ops.ts +173 -0
- package/src/plugins/git/index.ts +253 -0
- package/src/plugins/git/integration.test.ts +705 -0
- package/src/plugins/git/predicates.test.ts +1401 -0
- package/src/plugins/git/predicates.ts +693 -0
- package/src/plugins/git/rules.test.ts +1001 -0
- package/src/plugins/git/rules.ts +307 -0
- package/src/schema.test.ts +259 -0
- package/src/schema.ts +1963 -0
- package/src/testing/index.test.ts +1696 -0
- package/src/testing/index.ts +1472 -0
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for the v2 observer dispatcher (`buildObserverDispatcher`).
|
|
6
|
+
*
|
|
7
|
+
* Covers the semantics the ADR pins down for observer watch filters
|
|
8
|
+
* and dispatch behaviour:
|
|
9
|
+
*
|
|
10
|
+
* - no watch → fires on every tool_result,
|
|
11
|
+
* - toolName filter,
|
|
12
|
+
* - inputMatches (single + multi-key, absent keys fail-closed),
|
|
13
|
+
* - exitCode classification ("success" / "failure" / numeric / "any"),
|
|
14
|
+
* - observer throws → caught + logged, rest still fire,
|
|
15
|
+
* - appendEntry → visible to subsequent findEntries reads,
|
|
16
|
+
* - user observers fire before plugin observers (ordering),
|
|
17
|
+
* - plugin observers still fire without user observers,
|
|
18
|
+
* - dedup by name (user observer of the same name wins).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { describe, it, mock } from "node:test";
|
|
23
|
+
import type { ToolResultEvent } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { makeCtx, makeTrackedHost as makeHost } from "./__test-helpers__.ts";
|
|
25
|
+
import { buildObserverDispatcher } from "./observer-dispatcher.ts";
|
|
26
|
+
import { resolvePlugins } from "./plugin-merger.ts";
|
|
27
|
+
import type { Observer, Plugin } from "./schema.ts";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Event builders
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
//
|
|
33
|
+
// Tool-result event shape helpers stay local to this file — they're
|
|
34
|
+
// genuinely different from the tool_call event builders in
|
|
35
|
+
// evaluator.test.ts and don't share a helper surface.
|
|
36
|
+
|
|
37
|
+
function bashResult(command: string, exitCode: number): ToolResultEvent {
|
|
38
|
+
return {
|
|
39
|
+
type: "tool_result",
|
|
40
|
+
toolCallId: "t",
|
|
41
|
+
toolName: "bash",
|
|
42
|
+
input: { command },
|
|
43
|
+
content: [{ type: "text", text: "" }],
|
|
44
|
+
isError: exitCode !== 0,
|
|
45
|
+
details: {
|
|
46
|
+
command,
|
|
47
|
+
cwd: "/r",
|
|
48
|
+
exitCode,
|
|
49
|
+
stdout: "",
|
|
50
|
+
stderr: "",
|
|
51
|
+
durationMs: 0,
|
|
52
|
+
},
|
|
53
|
+
} as ToolResultEvent;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeResult(path: string, content: string): ToolResultEvent {
|
|
57
|
+
return {
|
|
58
|
+
type: "tool_result",
|
|
59
|
+
toolCallId: "t",
|
|
60
|
+
toolName: "write",
|
|
61
|
+
input: { path, content },
|
|
62
|
+
content: [{ type: "text", text: "" }],
|
|
63
|
+
isError: false,
|
|
64
|
+
details: undefined,
|
|
65
|
+
} as ToolResultEvent;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// No-watch observers
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
describe("buildObserverDispatcher: no watch", () => {
|
|
73
|
+
it("fires on every tool_result event", async () => {
|
|
74
|
+
let count = 0;
|
|
75
|
+
const obs: Observer = {
|
|
76
|
+
name: "all",
|
|
77
|
+
onResult: () => {
|
|
78
|
+
count++;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const dispatcher = buildObserverDispatcher(
|
|
82
|
+
resolvePlugins([], {}),
|
|
83
|
+
[obs],
|
|
84
|
+
makeHost(),
|
|
85
|
+
);
|
|
86
|
+
await dispatcher.dispatch(bashResult("ls", 0), makeCtx("/r"), 0);
|
|
87
|
+
await dispatcher.dispatch(writeResult("/r/a", "x"), makeCtx("/r"), 0);
|
|
88
|
+
assert.equal(count, 2);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// watch.toolName
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
describe("buildObserverDispatcher: watch.toolName", () => {
|
|
97
|
+
it("fires only on matching toolName", async () => {
|
|
98
|
+
let bashCount = 0;
|
|
99
|
+
let writeCount = 0;
|
|
100
|
+
const obsBash: Observer = {
|
|
101
|
+
name: "b",
|
|
102
|
+
watch: { toolName: "bash" },
|
|
103
|
+
onResult: () => {
|
|
104
|
+
bashCount++;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
const obsWrite: Observer = {
|
|
108
|
+
name: "w",
|
|
109
|
+
watch: { toolName: "write" },
|
|
110
|
+
onResult: () => {
|
|
111
|
+
writeCount++;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const dispatcher = buildObserverDispatcher(
|
|
115
|
+
resolvePlugins([], {}),
|
|
116
|
+
[obsBash, obsWrite],
|
|
117
|
+
makeHost(),
|
|
118
|
+
);
|
|
119
|
+
await dispatcher.dispatch(bashResult("ls", 0), makeCtx("/r"), 0);
|
|
120
|
+
await dispatcher.dispatch(writeResult("/r/a", "x"), makeCtx("/r"), 0);
|
|
121
|
+
assert.equal(bashCount, 1);
|
|
122
|
+
assert.equal(writeCount, 1);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// watch.inputMatches
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
describe("buildObserverDispatcher: watch.inputMatches", () => {
|
|
131
|
+
it("single-key regex partial-matches input string", async () => {
|
|
132
|
+
let count = 0;
|
|
133
|
+
const obs: Observer = {
|
|
134
|
+
name: "o",
|
|
135
|
+
watch: {
|
|
136
|
+
toolName: "bash",
|
|
137
|
+
inputMatches: { command: /git push/ },
|
|
138
|
+
},
|
|
139
|
+
onResult: () => {
|
|
140
|
+
count++;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
const dispatcher = buildObserverDispatcher(
|
|
144
|
+
resolvePlugins([], {}),
|
|
145
|
+
[obs],
|
|
146
|
+
makeHost(),
|
|
147
|
+
);
|
|
148
|
+
await dispatcher.dispatch(
|
|
149
|
+
bashResult("git push origin main", 0),
|
|
150
|
+
makeCtx("/r"),
|
|
151
|
+
0,
|
|
152
|
+
);
|
|
153
|
+
await dispatcher.dispatch(bashResult("git status", 0), makeCtx("/r"), 0);
|
|
154
|
+
assert.equal(count, 1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("multi-key filter: ALL keys must match", async () => {
|
|
158
|
+
let count = 0;
|
|
159
|
+
const obs: Observer = {
|
|
160
|
+
name: "o",
|
|
161
|
+
watch: {
|
|
162
|
+
toolName: "write",
|
|
163
|
+
inputMatches: { path: /\/config\//, content: /api_key/ },
|
|
164
|
+
},
|
|
165
|
+
onResult: () => {
|
|
166
|
+
count++;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
const dispatcher = buildObserverDispatcher(
|
|
170
|
+
resolvePlugins([], {}),
|
|
171
|
+
[obs],
|
|
172
|
+
makeHost(),
|
|
173
|
+
);
|
|
174
|
+
// Both match → fires.
|
|
175
|
+
await dispatcher.dispatch(
|
|
176
|
+
writeResult("/r/config/app.yml", "api_key: X"),
|
|
177
|
+
makeCtx("/r"),
|
|
178
|
+
0,
|
|
179
|
+
);
|
|
180
|
+
// path doesn't match → skipped.
|
|
181
|
+
await dispatcher.dispatch(
|
|
182
|
+
writeResult("/r/lib/app.yml", "api_key: X"),
|
|
183
|
+
makeCtx("/r"),
|
|
184
|
+
0,
|
|
185
|
+
);
|
|
186
|
+
// content doesn't match → skipped.
|
|
187
|
+
await dispatcher.dispatch(
|
|
188
|
+
writeResult("/r/config/app.yml", "foo: bar"),
|
|
189
|
+
makeCtx("/r"),
|
|
190
|
+
0,
|
|
191
|
+
);
|
|
192
|
+
assert.equal(count, 1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("inputMatches key absent from event → filter fails (fail-closed)", async () => {
|
|
196
|
+
let count = 0;
|
|
197
|
+
const obs: Observer = {
|
|
198
|
+
name: "o",
|
|
199
|
+
// `bogus` isn't a real bash input field.
|
|
200
|
+
watch: {
|
|
201
|
+
toolName: "bash",
|
|
202
|
+
inputMatches: { bogus: /anything/ },
|
|
203
|
+
},
|
|
204
|
+
onResult: () => {
|
|
205
|
+
count++;
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
const dispatcher = buildObserverDispatcher(
|
|
209
|
+
resolvePlugins([], {}),
|
|
210
|
+
[obs],
|
|
211
|
+
makeHost(),
|
|
212
|
+
);
|
|
213
|
+
await dispatcher.dispatch(bashResult("ls", 0), makeCtx("/r"), 0);
|
|
214
|
+
assert.equal(count, 0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("string pattern form compiles + matches", async () => {
|
|
218
|
+
let count = 0;
|
|
219
|
+
const obs: Observer = {
|
|
220
|
+
name: "o",
|
|
221
|
+
watch: {
|
|
222
|
+
toolName: "bash",
|
|
223
|
+
inputMatches: { command: "^git\\s+push\\b" },
|
|
224
|
+
},
|
|
225
|
+
onResult: () => {
|
|
226
|
+
count++;
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
const dispatcher = buildObserverDispatcher(
|
|
230
|
+
resolvePlugins([], {}),
|
|
231
|
+
[obs],
|
|
232
|
+
makeHost(),
|
|
233
|
+
);
|
|
234
|
+
await dispatcher.dispatch(
|
|
235
|
+
bashResult("git push origin", 0),
|
|
236
|
+
makeCtx("/r"),
|
|
237
|
+
0,
|
|
238
|
+
);
|
|
239
|
+
await dispatcher.dispatch(bashResult("echo git push", 0), makeCtx("/r"), 0);
|
|
240
|
+
assert.equal(count, 1);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// watch.inputMatches.command — wrapper-aware matching (ADR §12)
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
//
|
|
248
|
+
// `inputMatches.command` matches against the outer raw command AND
|
|
249
|
+
// against walker-extracted ref texts. So `sh -c 'git commit …'` with an
|
|
250
|
+
// anchored `/^git\s+commit/` pattern fires the observer — which is what
|
|
251
|
+
// rule authors intuitively expect once they've seen the evaluator treat
|
|
252
|
+
// the inner `git commit` as its own ref. Parse happens at most once
|
|
253
|
+
// per dispatch regardless of how many observers share the filter shape.
|
|
254
|
+
|
|
255
|
+
describe("buildObserverDispatcher: watch.inputMatches.command — wrapper-aware", () => {
|
|
256
|
+
it("matches `git commit` inside `sh -c '…'` (WRAP-1)", async () => {
|
|
257
|
+
let count = 0;
|
|
258
|
+
const obs: Observer = {
|
|
259
|
+
name: "o",
|
|
260
|
+
watch: {
|
|
261
|
+
toolName: "bash",
|
|
262
|
+
inputMatches: { command: /^git\s+commit/ },
|
|
263
|
+
},
|
|
264
|
+
onResult: () => {
|
|
265
|
+
count++;
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
const dispatcher = buildObserverDispatcher(
|
|
269
|
+
resolvePlugins([], {}),
|
|
270
|
+
[obs],
|
|
271
|
+
makeHost(),
|
|
272
|
+
);
|
|
273
|
+
await dispatcher.dispatch(
|
|
274
|
+
bashResult("sh -c 'git commit -m x'", 0),
|
|
275
|
+
makeCtx("/r"),
|
|
276
|
+
0,
|
|
277
|
+
);
|
|
278
|
+
assert.equal(count, 1);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("matches `git push` inside `sudo …` (WRAP-2)", async () => {
|
|
282
|
+
let count = 0;
|
|
283
|
+
const obs: Observer = {
|
|
284
|
+
name: "o",
|
|
285
|
+
watch: {
|
|
286
|
+
toolName: "bash",
|
|
287
|
+
inputMatches: { command: /^git\s+push/ },
|
|
288
|
+
},
|
|
289
|
+
onResult: () => {
|
|
290
|
+
count++;
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
const dispatcher = buildObserverDispatcher(
|
|
294
|
+
resolvePlugins([], {}),
|
|
295
|
+
[obs],
|
|
296
|
+
makeHost(),
|
|
297
|
+
);
|
|
298
|
+
await dispatcher.dispatch(bashResult("sudo git push", 0), makeCtx("/r"), 0);
|
|
299
|
+
assert.equal(count, 1);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("raw unwrapped command still matches (no regression)", async () => {
|
|
303
|
+
let count = 0;
|
|
304
|
+
const obs: Observer = {
|
|
305
|
+
name: "o",
|
|
306
|
+
watch: {
|
|
307
|
+
toolName: "bash",
|
|
308
|
+
inputMatches: { command: /^git\s+commit/ },
|
|
309
|
+
},
|
|
310
|
+
onResult: () => {
|
|
311
|
+
count++;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
const dispatcher = buildObserverDispatcher(
|
|
315
|
+
resolvePlugins([], {}),
|
|
316
|
+
[obs],
|
|
317
|
+
makeHost(),
|
|
318
|
+
);
|
|
319
|
+
await dispatcher.dispatch(
|
|
320
|
+
bashResult("git commit -m x", 0),
|
|
321
|
+
makeCtx("/r"),
|
|
322
|
+
0,
|
|
323
|
+
);
|
|
324
|
+
assert.equal(count, 1);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("quoted text inside `echo '…'` does NOT fire (echo is not a wrapper)", async () => {
|
|
328
|
+
let count = 0;
|
|
329
|
+
const obs: Observer = {
|
|
330
|
+
name: "o",
|
|
331
|
+
watch: {
|
|
332
|
+
toolName: "bash",
|
|
333
|
+
inputMatches: { command: /^git\s+commit/ },
|
|
334
|
+
},
|
|
335
|
+
onResult: () => {
|
|
336
|
+
count++;
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
const dispatcher = buildObserverDispatcher(
|
|
340
|
+
resolvePlugins([], {}),
|
|
341
|
+
[obs],
|
|
342
|
+
makeHost(),
|
|
343
|
+
);
|
|
344
|
+
// The walker extracts `echo` as the basename; the quoted arg is
|
|
345
|
+
// text data, not a command ref. Anchored `/^git\s+commit/`
|
|
346
|
+
// matches neither the raw `echo 'git commit'` nor the
|
|
347
|
+
// `echo git commit` ref text.
|
|
348
|
+
await dispatcher.dispatch(
|
|
349
|
+
bashResult("echo 'git commit'", 0),
|
|
350
|
+
makeCtx("/r"),
|
|
351
|
+
0,
|
|
352
|
+
);
|
|
353
|
+
assert.equal(count, 0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("matches an inner ref in a `cd A && git push` chain", async () => {
|
|
357
|
+
let count = 0;
|
|
358
|
+
const obs: Observer = {
|
|
359
|
+
name: "o",
|
|
360
|
+
watch: {
|
|
361
|
+
toolName: "bash",
|
|
362
|
+
inputMatches: { command: /^git\s+push/ },
|
|
363
|
+
},
|
|
364
|
+
onResult: () => {
|
|
365
|
+
count++;
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
const dispatcher = buildObserverDispatcher(
|
|
369
|
+
resolvePlugins([], {}),
|
|
370
|
+
[obs],
|
|
371
|
+
makeHost(),
|
|
372
|
+
);
|
|
373
|
+
// Outer raw command starts with `cd` — only the extracted
|
|
374
|
+
// `git push` ref hits the anchored pattern.
|
|
375
|
+
await dispatcher.dispatch(
|
|
376
|
+
bashResult("cd /tmp && git push", 0),
|
|
377
|
+
makeCtx("/r"),
|
|
378
|
+
0,
|
|
379
|
+
);
|
|
380
|
+
assert.equal(count, 1);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("inputMatches.path + inputMatches.command both enforced (AND)", async () => {
|
|
384
|
+
// When both keys are present and only one has wrapper analogue
|
|
385
|
+
// (command), make sure the other (path) still fails-closed on
|
|
386
|
+
// non-matching events.
|
|
387
|
+
let count = 0;
|
|
388
|
+
const obs: Observer = {
|
|
389
|
+
name: "o",
|
|
390
|
+
watch: {
|
|
391
|
+
toolName: "bash",
|
|
392
|
+
inputMatches: {
|
|
393
|
+
command: /^git\s+commit/,
|
|
394
|
+
path: /matches-nothing/,
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
onResult: () => {
|
|
398
|
+
count++;
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
const dispatcher = buildObserverDispatcher(
|
|
402
|
+
resolvePlugins([], {}),
|
|
403
|
+
[obs],
|
|
404
|
+
makeHost(),
|
|
405
|
+
);
|
|
406
|
+
// command matches via wrapper-aware expansion, but path is
|
|
407
|
+
// absent from bash events — AND-semantics still rejects.
|
|
408
|
+
await dispatcher.dispatch(
|
|
409
|
+
bashResult("sh -c 'git commit -m x'", 0),
|
|
410
|
+
makeCtx("/r"),
|
|
411
|
+
0,
|
|
412
|
+
);
|
|
413
|
+
assert.equal(count, 0);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("non-bash tool with inputMatches.command skips parse (perf guard)", async () => {
|
|
417
|
+
// inputMatches.command doesn't exist on write/edit events, so
|
|
418
|
+
// the key absence fails fail-closed BEFORE any parseBash call.
|
|
419
|
+
// Pin that behaviour by asserting the observer doesn't fire.
|
|
420
|
+
let count = 0;
|
|
421
|
+
const obs: Observer = {
|
|
422
|
+
name: "o",
|
|
423
|
+
watch: {
|
|
424
|
+
inputMatches: { command: /./ },
|
|
425
|
+
},
|
|
426
|
+
onResult: () => {
|
|
427
|
+
count++;
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
const dispatcher = buildObserverDispatcher(
|
|
431
|
+
resolvePlugins([], {}),
|
|
432
|
+
[obs],
|
|
433
|
+
makeHost(),
|
|
434
|
+
);
|
|
435
|
+
await dispatcher.dispatch(writeResult("/r/a", "x"), makeCtx("/r"), 0);
|
|
436
|
+
assert.equal(count, 0);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// watch.exitCode
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
describe("buildObserverDispatcher: watch.exitCode", () => {
|
|
445
|
+
it("'success' matches exit 0 only", async () => {
|
|
446
|
+
let count = 0;
|
|
447
|
+
const obs: Observer = {
|
|
448
|
+
name: "o",
|
|
449
|
+
watch: { toolName: "bash", exitCode: "success" },
|
|
450
|
+
onResult: () => {
|
|
451
|
+
count++;
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
const dispatcher = buildObserverDispatcher(
|
|
455
|
+
resolvePlugins([], {}),
|
|
456
|
+
[obs],
|
|
457
|
+
makeHost(),
|
|
458
|
+
);
|
|
459
|
+
await dispatcher.dispatch(bashResult("ok", 0), makeCtx("/r"), 0);
|
|
460
|
+
await dispatcher.dispatch(bashResult("ok", 1), makeCtx("/r"), 0);
|
|
461
|
+
assert.equal(count, 1);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("'failure' matches any non-zero exit", async () => {
|
|
465
|
+
let count = 0;
|
|
466
|
+
const obs: Observer = {
|
|
467
|
+
name: "o",
|
|
468
|
+
watch: { toolName: "bash", exitCode: "failure" },
|
|
469
|
+
onResult: () => {
|
|
470
|
+
count++;
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
const dispatcher = buildObserverDispatcher(
|
|
474
|
+
resolvePlugins([], {}),
|
|
475
|
+
[obs],
|
|
476
|
+
makeHost(),
|
|
477
|
+
);
|
|
478
|
+
await dispatcher.dispatch(bashResult("ok", 0), makeCtx("/r"), 0);
|
|
479
|
+
await dispatcher.dispatch(bashResult("fail", 1), makeCtx("/r"), 0);
|
|
480
|
+
await dispatcher.dispatch(bashResult("fail", 127), makeCtx("/r"), 0);
|
|
481
|
+
assert.equal(count, 2);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("numeric exitCode matches exact", async () => {
|
|
485
|
+
let count = 0;
|
|
486
|
+
const obs: Observer = {
|
|
487
|
+
name: "o",
|
|
488
|
+
watch: { toolName: "bash", exitCode: 127 },
|
|
489
|
+
onResult: () => {
|
|
490
|
+
count++;
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
const dispatcher = buildObserverDispatcher(
|
|
494
|
+
resolvePlugins([], {}),
|
|
495
|
+
[obs],
|
|
496
|
+
makeHost(),
|
|
497
|
+
);
|
|
498
|
+
await dispatcher.dispatch(bashResult("x", 127), makeCtx("/r"), 0);
|
|
499
|
+
await dispatcher.dispatch(bashResult("x", 1), makeCtx("/r"), 0);
|
|
500
|
+
assert.equal(count, 1);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("'any' matches every exit code", async () => {
|
|
504
|
+
let count = 0;
|
|
505
|
+
const obs: Observer = {
|
|
506
|
+
name: "o",
|
|
507
|
+
watch: { toolName: "bash", exitCode: "any" },
|
|
508
|
+
onResult: () => {
|
|
509
|
+
count++;
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
const dispatcher = buildObserverDispatcher(
|
|
513
|
+
resolvePlugins([], {}),
|
|
514
|
+
[obs],
|
|
515
|
+
makeHost(),
|
|
516
|
+
);
|
|
517
|
+
await dispatcher.dispatch(bashResult("x", 0), makeCtx("/r"), 0);
|
|
518
|
+
await dispatcher.dispatch(bashResult("x", 42), makeCtx("/r"), 0);
|
|
519
|
+
assert.equal(count, 2);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("omitted exitCode matches every exit code", async () => {
|
|
523
|
+
let count = 0;
|
|
524
|
+
const obs: Observer = {
|
|
525
|
+
name: "o",
|
|
526
|
+
watch: { toolName: "bash" },
|
|
527
|
+
onResult: () => {
|
|
528
|
+
count++;
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
const dispatcher = buildObserverDispatcher(
|
|
532
|
+
resolvePlugins([], {}),
|
|
533
|
+
[obs],
|
|
534
|
+
makeHost(),
|
|
535
|
+
);
|
|
536
|
+
await dispatcher.dispatch(bashResult("x", 0), makeCtx("/r"), 0);
|
|
537
|
+
await dispatcher.dispatch(bashResult("x", 1), makeCtx("/r"), 0);
|
|
538
|
+
assert.equal(count, 2);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("numeric exitCode on non-bash event never matches", async () => {
|
|
542
|
+
let count = 0;
|
|
543
|
+
const obs: Observer = {
|
|
544
|
+
name: "o",
|
|
545
|
+
watch: { toolName: "write", exitCode: 0 },
|
|
546
|
+
onResult: () => {
|
|
547
|
+
count++;
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
const dispatcher = buildObserverDispatcher(
|
|
551
|
+
resolvePlugins([], {}),
|
|
552
|
+
[obs],
|
|
553
|
+
makeHost(),
|
|
554
|
+
);
|
|
555
|
+
await dispatcher.dispatch(writeResult("/r/a", "x"), makeCtx("/r"), 0);
|
|
556
|
+
assert.equal(count, 0);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// multiple observers + fault isolation
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
describe("buildObserverDispatcher: multiple observers", () => {
|
|
565
|
+
it("every matching observer fires on one event", async () => {
|
|
566
|
+
let a = 0;
|
|
567
|
+
let b = 0;
|
|
568
|
+
const obs: Observer[] = [
|
|
569
|
+
{ name: "a", onResult: () => void a++ },
|
|
570
|
+
{ name: "b", onResult: () => void b++ },
|
|
571
|
+
];
|
|
572
|
+
const dispatcher = buildObserverDispatcher(
|
|
573
|
+
resolvePlugins([], {}),
|
|
574
|
+
obs,
|
|
575
|
+
makeHost(),
|
|
576
|
+
);
|
|
577
|
+
await dispatcher.dispatch(bashResult("ls", 0), makeCtx("/r"), 0);
|
|
578
|
+
assert.equal(a, 1);
|
|
579
|
+
assert.equal(b, 1);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("observer that throws is caught; subsequent observers still run", async () => {
|
|
583
|
+
let laterCount = 0;
|
|
584
|
+
const obs: Observer[] = [
|
|
585
|
+
{
|
|
586
|
+
name: "throws",
|
|
587
|
+
onResult: () => {
|
|
588
|
+
throw new Error("boom");
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
{ name: "later", onResult: () => void laterCount++ },
|
|
592
|
+
];
|
|
593
|
+
// Silence console.warn so the test output stays clean.
|
|
594
|
+
const warn = mock.method(console, "warn", () => {});
|
|
595
|
+
const dispatcher = buildObserverDispatcher(
|
|
596
|
+
resolvePlugins([], {}),
|
|
597
|
+
obs,
|
|
598
|
+
makeHost(),
|
|
599
|
+
);
|
|
600
|
+
await dispatcher.dispatch(bashResult("ls", 0), makeCtx("/r"), 0);
|
|
601
|
+
assert.equal(laterCount, 1);
|
|
602
|
+
assert.equal(warn.mock.callCount(), 1);
|
|
603
|
+
assert.match(
|
|
604
|
+
String(warn.mock.calls[0]!.arguments[0]),
|
|
605
|
+
/observer "throws" threw/,
|
|
606
|
+
);
|
|
607
|
+
warn.mock.restore();
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("async observer rejection is caught too", async () => {
|
|
611
|
+
let laterCount = 0;
|
|
612
|
+
const obs: Observer[] = [
|
|
613
|
+
{
|
|
614
|
+
name: "throws-async",
|
|
615
|
+
onResult: async () => {
|
|
616
|
+
throw new Error("boom-async");
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
{ name: "later", onResult: () => void laterCount++ },
|
|
620
|
+
];
|
|
621
|
+
const warn = mock.method(console, "warn", () => {});
|
|
622
|
+
const dispatcher = buildObserverDispatcher(
|
|
623
|
+
resolvePlugins([], {}),
|
|
624
|
+
obs,
|
|
625
|
+
makeHost(),
|
|
626
|
+
);
|
|
627
|
+
await dispatcher.dispatch(bashResult("ls", 0), makeCtx("/r"), 0);
|
|
628
|
+
assert.equal(laterCount, 1);
|
|
629
|
+
assert.equal(warn.mock.callCount(), 1);
|
|
630
|
+
warn.mock.restore();
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
// appendEntry + findEntries interaction
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
describe("buildObserverDispatcher: appendEntry + findEntries", () => {
|
|
639
|
+
it("observer appendEntry is visible to subsequent findEntries on the same ctx", async () => {
|
|
640
|
+
const host = makeHost();
|
|
641
|
+
const ctx = makeCtx("/r", host.entries);
|
|
642
|
+
|
|
643
|
+
let seenAcross: Array<{ data: unknown }> = [];
|
|
644
|
+
const obs: Observer[] = [
|
|
645
|
+
{
|
|
646
|
+
name: "writer",
|
|
647
|
+
watch: { toolName: "bash" },
|
|
648
|
+
onResult: (_e, ctx) => {
|
|
649
|
+
ctx.appendEntry("marker", { n: 1 });
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
name: "reader",
|
|
654
|
+
watch: { toolName: "bash" },
|
|
655
|
+
onResult: (_e, ctx) => {
|
|
656
|
+
seenAcross = ctx.findEntries<{ n: number }>("marker");
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
];
|
|
660
|
+
const dispatcher = buildObserverDispatcher(
|
|
661
|
+
resolvePlugins([], {}),
|
|
662
|
+
obs,
|
|
663
|
+
host,
|
|
664
|
+
);
|
|
665
|
+
await dispatcher.dispatch(bashResult("ls", 0), ctx, 7);
|
|
666
|
+
assert.equal(host.appended.length, 1);
|
|
667
|
+
assert.equal(seenAcross.length, 1);
|
|
668
|
+
// Observer appendEntry auto-tags with the current agentLoopIndex so
|
|
669
|
+
// rules using `when.happened` can filter by scope. The reader sees
|
|
670
|
+
// the tagged shape via findEntries.
|
|
671
|
+
assert.deepEqual(seenAcross[0]!.data, { n: 1, _agentLoopIndex: 7 });
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("observerCtx.agentLoopIndex is threaded from dispatch args", async () => {
|
|
675
|
+
let seen = -1;
|
|
676
|
+
const obs: Observer = {
|
|
677
|
+
name: "t",
|
|
678
|
+
onResult: (_e, ctx) => {
|
|
679
|
+
seen = ctx.agentLoopIndex;
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
const dispatcher = buildObserverDispatcher(
|
|
683
|
+
resolvePlugins([], {}),
|
|
684
|
+
[obs],
|
|
685
|
+
makeHost(),
|
|
686
|
+
);
|
|
687
|
+
await dispatcher.dispatch(bashResult("x", 0), makeCtx("/r"), 42);
|
|
688
|
+
assert.equal(seen, 42);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("observer appendEntry auto-injects _agentLoopIndex into object payloads", async () => {
|
|
692
|
+
const host = makeHost();
|
|
693
|
+
const obs: Observer = {
|
|
694
|
+
name: "w",
|
|
695
|
+
onResult: (_e, ctx) => {
|
|
696
|
+
ctx.appendEntry("marker", { foo: "bar" });
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
const dispatcher = buildObserverDispatcher(
|
|
700
|
+
resolvePlugins([], {}),
|
|
701
|
+
[obs],
|
|
702
|
+
host,
|
|
703
|
+
);
|
|
704
|
+
await dispatcher.dispatch(bashResult("x", 0), makeCtx("/r"), 9);
|
|
705
|
+
assert.equal(host.appended.length, 1);
|
|
706
|
+
assert.deepEqual(host.appended[0]!.data, {
|
|
707
|
+
foo: "bar",
|
|
708
|
+
_agentLoopIndex: 9,
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("observer appendEntry wraps primitive / undefined payloads as { value, _agentLoopIndex }", async () => {
|
|
713
|
+
const host = makeHost();
|
|
714
|
+
const obs: Observer = {
|
|
715
|
+
name: "w",
|
|
716
|
+
onResult: (_e, ctx) => {
|
|
717
|
+
ctx.appendEntry("no-data");
|
|
718
|
+
ctx.appendEntry("str", "hello");
|
|
719
|
+
ctx.appendEntry("num", 42);
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
const dispatcher = buildObserverDispatcher(
|
|
723
|
+
resolvePlugins([], {}),
|
|
724
|
+
[obs],
|
|
725
|
+
host,
|
|
726
|
+
);
|
|
727
|
+
await dispatcher.dispatch(bashResult("x", 0), makeCtx("/r"), 3);
|
|
728
|
+
assert.equal(host.appended.length, 3);
|
|
729
|
+
assert.deepEqual(host.appended[0]!.data, {
|
|
730
|
+
value: undefined,
|
|
731
|
+
_agentLoopIndex: 3,
|
|
732
|
+
});
|
|
733
|
+
assert.deepEqual(host.appended[1]!.data, {
|
|
734
|
+
value: "hello",
|
|
735
|
+
_agentLoopIndex: 3,
|
|
736
|
+
});
|
|
737
|
+
assert.deepEqual(host.appended[2]!.data, {
|
|
738
|
+
value: 42,
|
|
739
|
+
_agentLoopIndex: 3,
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it("cross-observer: appendEntry invalidates a sibling observer's cached findEntries read (S2/E1)", async () => {
|
|
744
|
+
// Three observers firing on the same event:
|
|
745
|
+
// 1. reader1 — reads "marker", caches the empty list.
|
|
746
|
+
// 2. writer — appendEntry("marker", …).
|
|
747
|
+
// 3. reader2 — reads "marker" again.
|
|
748
|
+
// Pre-S2: reader2 saw the cached empty list because the cache was
|
|
749
|
+
// not invalidated on write. With S2, writer's appendEntry drops
|
|
750
|
+
// the "marker" cache slot so reader2 re-reads sessionManager and
|
|
751
|
+
// sees the fresh entry.
|
|
752
|
+
const host = makeHost();
|
|
753
|
+
const ctx = makeCtx("/r", host.entries);
|
|
754
|
+
let reader1Count: number | null = null;
|
|
755
|
+
let reader2Count: number | null = null;
|
|
756
|
+
const obs: Observer[] = [
|
|
757
|
+
{
|
|
758
|
+
name: "reader1",
|
|
759
|
+
onResult: (_e, c) => {
|
|
760
|
+
reader1Count = c.findEntries("marker").length;
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
name: "writer",
|
|
765
|
+
onResult: (_e, c) => {
|
|
766
|
+
c.appendEntry("marker", { n: 1 });
|
|
767
|
+
},
|
|
768
|
+
},
|
|
769
|
+
{
|
|
770
|
+
name: "reader2",
|
|
771
|
+
onResult: (_e, c) => {
|
|
772
|
+
reader2Count = c.findEntries("marker").length;
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
];
|
|
776
|
+
const dispatcher = buildObserverDispatcher(
|
|
777
|
+
resolvePlugins([], {}),
|
|
778
|
+
obs,
|
|
779
|
+
host,
|
|
780
|
+
);
|
|
781
|
+
await dispatcher.dispatch(bashResult("ls", 0), ctx, 5);
|
|
782
|
+
assert.equal(reader1Count, 0, "reader1 before the write sees zero");
|
|
783
|
+
assert.equal(
|
|
784
|
+
reader2Count,
|
|
785
|
+
1,
|
|
786
|
+
"reader2 after the write must see the fresh entry (S2/E1)",
|
|
787
|
+
);
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
// user / plugin merge + ordering
|
|
793
|
+
// ---------------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
describe("buildObserverDispatcher: user + plugin merge", () => {
|
|
796
|
+
it("user observers fire BEFORE plugin observers on the same event", async () => {
|
|
797
|
+
const order: string[] = [];
|
|
798
|
+
const userObs: Observer = {
|
|
799
|
+
name: "user",
|
|
800
|
+
onResult: () => {
|
|
801
|
+
order.push("user");
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
const plugin: Plugin = {
|
|
805
|
+
name: "p",
|
|
806
|
+
observers: [
|
|
807
|
+
{
|
|
808
|
+
name: "plugin",
|
|
809
|
+
onResult: () => {
|
|
810
|
+
order.push("plugin");
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
],
|
|
814
|
+
};
|
|
815
|
+
const dispatcher = buildObserverDispatcher(
|
|
816
|
+
resolvePlugins([plugin], {}),
|
|
817
|
+
[userObs],
|
|
818
|
+
makeHost(),
|
|
819
|
+
);
|
|
820
|
+
await dispatcher.dispatch(bashResult("ls", 0), makeCtx("/r"), 0);
|
|
821
|
+
assert.deepEqual(order, ["user", "plugin"]);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it("plugin observers fire when user observers empty", async () => {
|
|
825
|
+
let hits = 0;
|
|
826
|
+
const plugin: Plugin = {
|
|
827
|
+
name: "p",
|
|
828
|
+
observers: [{ name: "plug", onResult: () => void hits++ }],
|
|
829
|
+
};
|
|
830
|
+
const dispatcher = buildObserverDispatcher(
|
|
831
|
+
resolvePlugins([plugin], {}),
|
|
832
|
+
[],
|
|
833
|
+
makeHost(),
|
|
834
|
+
);
|
|
835
|
+
await dispatcher.dispatch(bashResult("ls", 0), makeCtx("/r"), 0);
|
|
836
|
+
assert.equal(hits, 1);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("user observer with same name as plugin observer wins (dedup)", async () => {
|
|
840
|
+
const order: string[] = [];
|
|
841
|
+
const user: Observer = {
|
|
842
|
+
name: "both",
|
|
843
|
+
onResult: () => {
|
|
844
|
+
order.push("user");
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
const plugin: Plugin = {
|
|
848
|
+
name: "p",
|
|
849
|
+
observers: [
|
|
850
|
+
{
|
|
851
|
+
name: "both",
|
|
852
|
+
onResult: () => {
|
|
853
|
+
order.push("plugin");
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
],
|
|
857
|
+
};
|
|
858
|
+
const dispatcher = buildObserverDispatcher(
|
|
859
|
+
resolvePlugins([plugin], {}),
|
|
860
|
+
[user],
|
|
861
|
+
makeHost(),
|
|
862
|
+
);
|
|
863
|
+
await dispatcher.dispatch(bashResult("ls", 0), makeCtx("/r"), 0);
|
|
864
|
+
// Only user fires; plugin-side duplicate is suppressed.
|
|
865
|
+
assert.deepEqual(order, ["user"]);
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// ---------------------------------------------------------------------------
|
|
870
|
+
// Event shape visible to handlers
|
|
871
|
+
// ---------------------------------------------------------------------------
|
|
872
|
+
|
|
873
|
+
describe("buildObserverDispatcher: schema event shape", () => {
|
|
874
|
+
it("handler sees toolName / input / output / exitCode (bash)", async () => {
|
|
875
|
+
let captured: unknown = null;
|
|
876
|
+
const obs: Observer = {
|
|
877
|
+
name: "capture",
|
|
878
|
+
onResult: (e) => {
|
|
879
|
+
captured = e;
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
const dispatcher = buildObserverDispatcher(
|
|
883
|
+
resolvePlugins([], {}),
|
|
884
|
+
[obs],
|
|
885
|
+
makeHost(),
|
|
886
|
+
);
|
|
887
|
+
await dispatcher.dispatch(bashResult("whoami", 0), makeCtx("/r"), 0);
|
|
888
|
+
const ev = captured as {
|
|
889
|
+
toolName: string;
|
|
890
|
+
input: unknown;
|
|
891
|
+
output: unknown;
|
|
892
|
+
exitCode?: number;
|
|
893
|
+
};
|
|
894
|
+
assert.equal(ev.toolName, "bash");
|
|
895
|
+
assert.deepEqual(ev.input, { command: "whoami" });
|
|
896
|
+
assert.equal(ev.exitCode, 0);
|
|
897
|
+
assert.ok(Array.isArray(ev.output));
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it("non-bash event has undefined exitCode", async () => {
|
|
901
|
+
let captured: unknown = null;
|
|
902
|
+
const obs: Observer = {
|
|
903
|
+
name: "capture",
|
|
904
|
+
onResult: (e) => {
|
|
905
|
+
captured = e;
|
|
906
|
+
},
|
|
907
|
+
};
|
|
908
|
+
const dispatcher = buildObserverDispatcher(
|
|
909
|
+
resolvePlugins([], {}),
|
|
910
|
+
[obs],
|
|
911
|
+
makeHost(),
|
|
912
|
+
);
|
|
913
|
+
await dispatcher.dispatch(writeResult("/r/a", "x"), makeCtx("/r"), 0);
|
|
914
|
+
const ev = captured as { exitCode?: number };
|
|
915
|
+
assert.equal(ev.exitCode, undefined);
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
// ---------------------------------------------------------------------------
|
|
920
|
+
// S3: name validation (user-supplied observers via buildObserverDispatcher)
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
|
|
923
|
+
describe("buildObserverDispatcher: user observer-name validation (S3)", () => {
|
|
924
|
+
it("throws when a user observer name contains disallowed chars", () => {
|
|
925
|
+
const obs: Observer = {
|
|
926
|
+
name: "bad name",
|
|
927
|
+
onResult: () => {},
|
|
928
|
+
};
|
|
929
|
+
assert.throws(
|
|
930
|
+
() => buildObserverDispatcher(resolvePlugins([], {}), [obs], makeHost()),
|
|
931
|
+
/observer name "bad name".*disallowed/,
|
|
932
|
+
);
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
// Keep the pi `ToolResultEvent` import from appearing unused if future
|
|
937
|
+
// refactors remove the event-shape test above.
|
|
938
|
+
const _resultTypeKeepalive = null as unknown as ToolResultEvent | null;
|
|
939
|
+
void _resultTypeKeepalive;
|