@tekyzinc/gsd-t 2.74.13 → 3.10.10
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 +165 -0
- package/README.md +117 -1
- package/bin/advisor-integration.js +93 -0
- package/bin/check-headless-sessions.js +140 -0
- package/bin/context-meter-config.cjs +101 -0
- package/bin/context-meter-config.test.cjs +101 -0
- package/bin/gsd-t-unattended-platform.js +381 -0
- package/bin/gsd-t-unattended-safety.js +766 -0
- package/bin/gsd-t-unattended.js +1259 -0
- package/bin/gsd-t.js +723 -19
- package/bin/handoff-lock.js +249 -0
- package/bin/headless-auto-spawn.js +328 -0
- package/bin/model-selector.js +224 -0
- package/bin/runway-estimator.js +242 -0
- package/bin/token-budget.js +96 -89
- package/bin/token-optimizer.js +471 -0
- package/bin/token-telemetry.js +246 -0
- package/commands/gsd-t-audit.md +3 -3
- package/commands/gsd-t-backlog-list.md +38 -0
- package/commands/gsd-t-brainstorm.md +3 -3
- package/commands/gsd-t-complete-milestone.md +24 -0
- package/commands/gsd-t-debug.md +124 -7
- package/commands/gsd-t-discuss.md +10 -3
- package/commands/gsd-t-doc-ripple.md +32 -4
- package/commands/gsd-t-execute.md +107 -52
- package/commands/gsd-t-help.md +22 -0
- package/commands/gsd-t-integrate.md +67 -4
- package/commands/gsd-t-optimization-apply.md +91 -0
- package/commands/gsd-t-optimization-reject.md +94 -0
- package/commands/gsd-t-partition.md +7 -0
- package/commands/gsd-t-pause.md +3 -0
- package/commands/gsd-t-plan.md +10 -3
- package/commands/gsd-t-prd.md +3 -3
- package/commands/gsd-t-quick.md +71 -9
- package/commands/gsd-t-reflect.md +3 -7
- package/commands/gsd-t-resume.md +86 -1
- package/commands/gsd-t-status.md +31 -0
- package/commands/gsd-t-test-sync.md +7 -0
- package/commands/gsd-t-unattended-stop.md +83 -0
- package/commands/gsd-t-unattended-watch.md +290 -0
- package/commands/gsd-t-unattended.md +414 -0
- package/commands/gsd-t-verify.md +12 -5
- package/commands/gsd-t-visualize.md +3 -7
- package/commands/gsd-t-wave.md +82 -18
- package/docs/GSD-T-README.md +69 -0
- package/docs/architecture.md +176 -4
- package/docs/infrastructure.md +221 -0
- package/docs/methodology.md +44 -0
- package/docs/prd-harness-evolution.md +51 -37
- package/docs/requirements.md +95 -0
- package/docs/unattended-windows-caveats.md +245 -0
- package/package.json +2 -2
- package/scripts/context-meter/count-tokens-client.js +221 -0
- package/scripts/context-meter/count-tokens-client.test.js +308 -0
- package/scripts/context-meter/test-injector.js +55 -0
- package/scripts/context-meter/threshold.js +88 -0
- package/scripts/context-meter/threshold.test.js +255 -0
- package/scripts/context-meter/transcript-parser.js +252 -0
- package/scripts/context-meter/transcript-parser.test.js +320 -0
- package/scripts/gsd-t-context-meter.e2e.test.js +415 -0
- package/scripts/gsd-t-context-meter.js +350 -0
- package/scripts/gsd-t-context-meter.test.js +417 -0
- package/scripts/gsd-t-heartbeat.js +2 -2
- package/scripts/gsd-t-statusline.js +23 -8
- package/templates/CLAUDE-global.md +17 -1
- package/templates/CLAUDE-project.md +26 -6
- package/templates/context-meter-config.json +10 -0
- package/templates/prompts/README.md +1 -1
- package/bin/task-counter.cjs +0 -161
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gsd-t-context-meter.e2e.test.js — black-box E2E integration test for M34.
|
|
3
|
+
*
|
|
4
|
+
* TEST-ONLY FILE. Not shipped to users. Does not participate in production
|
|
5
|
+
* require graphs. Spawned as part of `node --test` only.
|
|
6
|
+
*
|
|
7
|
+
* Tasks 1–4 of the context-meter-hook domain unit-tested `runMeter()` via
|
|
8
|
+
* dependency injection. This test exercises the real child-process hook as
|
|
9
|
+
* Claude Code would invoke it:
|
|
10
|
+
*
|
|
11
|
+
* 1. A temporary project root is constructed under os.tmpdir() containing:
|
|
12
|
+
* - .gsd-t/context-meter-config.json (real config loader target)
|
|
13
|
+
* - transcript.jsonl (minimal Claude-Code-shaped transcript)
|
|
14
|
+
* 2. A local stub HTTP server mimics POST /v1/messages/count_tokens and
|
|
15
|
+
* returns a configurable `input_tokens` value.
|
|
16
|
+
* 3. `node scripts/gsd-t-context-meter.js` is spawned as a child process
|
|
17
|
+
* with cwd = tempdir, NODE_OPTIONS = --require <test-injector>, and
|
|
18
|
+
* GSD_T_CONTEXT_METER_TEST_BASE_URL pointing at the stub.
|
|
19
|
+
* 4. We write the PostToolUse JSON payload to the child's stdin, close
|
|
20
|
+
* stdin, collect stdout, and assert both the stdout shape and the
|
|
21
|
+
* on-disk state file.
|
|
22
|
+
*
|
|
23
|
+
* The test-injector.js file is the single unavoidable bit of test-only
|
|
24
|
+
* infrastructure: the production hook's CLI shim takes no base-URL override
|
|
25
|
+
* (by design — production must not be routable to a non-Anthropic host),
|
|
26
|
+
* so redirecting HTTP in a black-box test requires a --require-level
|
|
27
|
+
* monkey-patch inside the child process. See that file's comment block.
|
|
28
|
+
*
|
|
29
|
+
* Timing budget: each test < 2s, whole suite < 10s. Hard timeouts on every
|
|
30
|
+
* async wait prevent suite hangs on unclosed sockets or child processes.
|
|
31
|
+
*
|
|
32
|
+
* @module scripts/gsd-t-context-meter.e2e.test
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
"use strict";
|
|
36
|
+
|
|
37
|
+
const { test, beforeEach, afterEach } = require("node:test");
|
|
38
|
+
const assert = require("node:assert/strict");
|
|
39
|
+
const { spawn } = require("node:child_process");
|
|
40
|
+
const http = require("node:http");
|
|
41
|
+
const fs = require("node:fs");
|
|
42
|
+
const path = require("node:path");
|
|
43
|
+
const os = require("node:os");
|
|
44
|
+
|
|
45
|
+
const HOOK_SCRIPT = path.resolve(__dirname, "gsd-t-context-meter.js");
|
|
46
|
+
const INJECTOR = path.resolve(__dirname, "context-meter", "test-injector.js");
|
|
47
|
+
const HARD_TIMEOUT_MS = 12000;
|
|
48
|
+
|
|
49
|
+
/* ──────────────────────────── test fixtures ──────────────────────────── */
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sandbox state for a single test. Holds the tempdir, stub server, and a
|
|
53
|
+
* dispose() that guarantees everything is torn down — even on failure.
|
|
54
|
+
*/
|
|
55
|
+
class Sandbox {
|
|
56
|
+
constructor() {
|
|
57
|
+
this.tempdir = null;
|
|
58
|
+
this.server = null;
|
|
59
|
+
this.serverUrl = null;
|
|
60
|
+
this.hitCount = 0;
|
|
61
|
+
this.childProcs = [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async setup() {
|
|
65
|
+
this.tempdir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-t-cm-e2e-"));
|
|
66
|
+
fs.mkdirSync(path.join(this.tempdir, ".gsd-t"), { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
writeConfig(config) {
|
|
70
|
+
const full = Object.assign(
|
|
71
|
+
{
|
|
72
|
+
version: 1,
|
|
73
|
+
thresholdPct: 75,
|
|
74
|
+
modelWindowSize: 200000,
|
|
75
|
+
checkFrequency: 1,
|
|
76
|
+
apiKeyEnvVar: "ANTHROPIC_API_KEY",
|
|
77
|
+
statePath: ".gsd-t/.context-meter-state.json",
|
|
78
|
+
logPath: ".gsd-t/context-meter.log",
|
|
79
|
+
timeoutMs: 2000,
|
|
80
|
+
},
|
|
81
|
+
config || {}
|
|
82
|
+
);
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
path.join(this.tempdir, ".gsd-t", "context-meter-config.json"),
|
|
85
|
+
JSON.stringify(full, null, 2),
|
|
86
|
+
"utf8"
|
|
87
|
+
);
|
|
88
|
+
return full;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write a minimal Claude-Code transcript JSONL containing one user turn and
|
|
93
|
+
* one assistant turn — enough for parseTranscript() to return a non-empty
|
|
94
|
+
* messages array.
|
|
95
|
+
*/
|
|
96
|
+
writeTranscript(filename = "transcript.jsonl") {
|
|
97
|
+
const lines = [
|
|
98
|
+
JSON.stringify({
|
|
99
|
+
type: "user",
|
|
100
|
+
message: { role: "user", content: "hello world" },
|
|
101
|
+
uuid: "u1",
|
|
102
|
+
sessionId: "sess-1",
|
|
103
|
+
}),
|
|
104
|
+
JSON.stringify({
|
|
105
|
+
type: "assistant",
|
|
106
|
+
message: {
|
|
107
|
+
role: "assistant",
|
|
108
|
+
content: [{ type: "text", text: "hi there" }],
|
|
109
|
+
model: "claude-opus-4-6",
|
|
110
|
+
},
|
|
111
|
+
uuid: "a1",
|
|
112
|
+
sessionId: "sess-1",
|
|
113
|
+
}),
|
|
114
|
+
];
|
|
115
|
+
const p = path.join(this.tempdir, filename);
|
|
116
|
+
fs.writeFileSync(p, lines.join("\n") + "\n", "utf8");
|
|
117
|
+
return p;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Optional: pre-seed the state file so we can test the checkFrequency skip
|
|
122
|
+
* path (where runMeter increments but does not call the API).
|
|
123
|
+
*/
|
|
124
|
+
writeState(state) {
|
|
125
|
+
const full = Object.assign(
|
|
126
|
+
{
|
|
127
|
+
version: 1,
|
|
128
|
+
timestamp: null,
|
|
129
|
+
inputTokens: 0,
|
|
130
|
+
modelWindowSize: 0,
|
|
131
|
+
pct: 0,
|
|
132
|
+
threshold: "normal",
|
|
133
|
+
checkCount: 0,
|
|
134
|
+
lastError: null,
|
|
135
|
+
},
|
|
136
|
+
state || {}
|
|
137
|
+
);
|
|
138
|
+
fs.writeFileSync(
|
|
139
|
+
path.join(this.tempdir, ".gsd-t", ".context-meter-state.json"),
|
|
140
|
+
JSON.stringify(full, null, 2),
|
|
141
|
+
"utf8"
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Start a local stub HTTP server that responds to every request with the
|
|
147
|
+
* given inputTokens value. Tracks hit count so tests can assert the API
|
|
148
|
+
* was (or was not) called.
|
|
149
|
+
*/
|
|
150
|
+
async startStub({ inputTokens }) {
|
|
151
|
+
this.server = http.createServer((req, res) => {
|
|
152
|
+
this.hitCount++;
|
|
153
|
+
// Drain the request body (even though we don't inspect it) so the
|
|
154
|
+
// client sees a clean close.
|
|
155
|
+
req.on("data", () => {});
|
|
156
|
+
req.on("end", () => {
|
|
157
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
158
|
+
res.end(JSON.stringify({ input_tokens: inputTokens }));
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
await new Promise((resolve, reject) => {
|
|
162
|
+
const t = setTimeout(
|
|
163
|
+
() => reject(new Error("stub server listen timeout")),
|
|
164
|
+
HARD_TIMEOUT_MS
|
|
165
|
+
);
|
|
166
|
+
this.server.on("error", (err) => {
|
|
167
|
+
clearTimeout(t);
|
|
168
|
+
reject(err);
|
|
169
|
+
});
|
|
170
|
+
this.server.listen(0, "127.0.0.1", () => {
|
|
171
|
+
clearTimeout(t);
|
|
172
|
+
const { port } = this.server.address();
|
|
173
|
+
this.serverUrl = `http://127.0.0.1:${port}`;
|
|
174
|
+
resolve();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Spawn the real hook as a child process, write a payload to stdin, and
|
|
181
|
+
* resolve with { stdout, stderr, code }. Enforces a hard timeout so the
|
|
182
|
+
* test can never hang the suite.
|
|
183
|
+
*/
|
|
184
|
+
async runHook({ payload, env }) {
|
|
185
|
+
const fullEnv = Object.assign({}, process.env, {
|
|
186
|
+
ANTHROPIC_API_KEY: "test-key-ignored",
|
|
187
|
+
GSD_T_CONTEXT_METER_TEST_BASE_URL: this.serverUrl || "",
|
|
188
|
+
NODE_OPTIONS: `--require ${INJECTOR}`,
|
|
189
|
+
});
|
|
190
|
+
// Allow caller to override any env (including unsetting ANTHROPIC_API_KEY).
|
|
191
|
+
if (env) {
|
|
192
|
+
for (const [k, v] of Object.entries(env)) {
|
|
193
|
+
if (v === null || v === undefined) {
|
|
194
|
+
delete fullEnv[k];
|
|
195
|
+
} else {
|
|
196
|
+
fullEnv[k] = v;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const child = spawn(process.execPath, [HOOK_SCRIPT], {
|
|
202
|
+
cwd: this.tempdir,
|
|
203
|
+
env: fullEnv,
|
|
204
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
205
|
+
});
|
|
206
|
+
this.childProcs.push(child);
|
|
207
|
+
|
|
208
|
+
const stdoutChunks = [];
|
|
209
|
+
const stderrChunks = [];
|
|
210
|
+
child.stdout.on("data", (c) => stdoutChunks.push(c));
|
|
211
|
+
child.stderr.on("data", (c) => stderrChunks.push(c));
|
|
212
|
+
|
|
213
|
+
child.stdin.write(JSON.stringify(payload || {}));
|
|
214
|
+
child.stdin.end();
|
|
215
|
+
|
|
216
|
+
const result = await new Promise((resolve, reject) => {
|
|
217
|
+
const killTimer = setTimeout(() => {
|
|
218
|
+
try {
|
|
219
|
+
child.kill("SIGKILL");
|
|
220
|
+
} catch (_) {
|
|
221
|
+
/* ignore */
|
|
222
|
+
}
|
|
223
|
+
reject(new Error(`hook child process timeout after ${HARD_TIMEOUT_MS}ms`));
|
|
224
|
+
}, HARD_TIMEOUT_MS);
|
|
225
|
+
|
|
226
|
+
child.on("close", (code) => {
|
|
227
|
+
clearTimeout(killTimer);
|
|
228
|
+
resolve({
|
|
229
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
230
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
231
|
+
code,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
child.on("error", (err) => {
|
|
235
|
+
clearTimeout(killTimer);
|
|
236
|
+
reject(err);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
readState() {
|
|
244
|
+
const p = path.join(this.tempdir, ".gsd-t", ".context-meter-state.json");
|
|
245
|
+
if (!fs.existsSync(p)) return null;
|
|
246
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
stateFileExists() {
|
|
250
|
+
const p = path.join(this.tempdir, ".gsd-t", ".context-meter-state.json");
|
|
251
|
+
return fs.existsSync(p);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
tmpFileExists() {
|
|
255
|
+
const p = path.join(this.tempdir, ".gsd-t", ".context-meter-state.json.tmp");
|
|
256
|
+
return fs.existsSync(p);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async dispose() {
|
|
260
|
+
// Kill any lingering children first.
|
|
261
|
+
for (const c of this.childProcs) {
|
|
262
|
+
try {
|
|
263
|
+
if (!c.killed) c.kill("SIGKILL");
|
|
264
|
+
} catch (_) {
|
|
265
|
+
/* ignore */
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
this.childProcs = [];
|
|
269
|
+
|
|
270
|
+
if (this.server) {
|
|
271
|
+
await new Promise((resolve) => {
|
|
272
|
+
try {
|
|
273
|
+
this.server.close(() => resolve());
|
|
274
|
+
} catch (_) {
|
|
275
|
+
resolve();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
this.server = null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (this.tempdir) {
|
|
282
|
+
try {
|
|
283
|
+
fs.rmSync(this.tempdir, { recursive: true, force: true });
|
|
284
|
+
} catch (_) {
|
|
285
|
+
/* ignore */
|
|
286
|
+
}
|
|
287
|
+
this.tempdir = null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* ──────────────────────────── shared state ──────────────────────────── */
|
|
293
|
+
|
|
294
|
+
let sandbox;
|
|
295
|
+
|
|
296
|
+
beforeEach(async () => {
|
|
297
|
+
sandbox = new Sandbox();
|
|
298
|
+
await sandbox.setup();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
afterEach(async () => {
|
|
302
|
+
if (sandbox) {
|
|
303
|
+
await sandbox.dispose();
|
|
304
|
+
sandbox = null;
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
/* ──────────────────────────── tests ──────────────────────────── */
|
|
309
|
+
|
|
310
|
+
test("E2E 1. below threshold — stdout {} and state reflects 25%", async () => {
|
|
311
|
+
sandbox.writeConfig({ thresholdPct: 75, modelWindowSize: 200000, checkFrequency: 1 });
|
|
312
|
+
const transcriptPath = sandbox.writeTranscript();
|
|
313
|
+
await sandbox.startStub({ inputTokens: 50000 });
|
|
314
|
+
|
|
315
|
+
const { stdout, code } = await sandbox.runHook({
|
|
316
|
+
payload: { session_id: "test-below", transcript_path: transcriptPath },
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
assert.equal(code, 0, "hook should always exit 0");
|
|
320
|
+
const parsed = JSON.parse(stdout || "{}");
|
|
321
|
+
assert.deepEqual(parsed, {}, "below-threshold stdout must be exactly {}");
|
|
322
|
+
|
|
323
|
+
const state = sandbox.readState();
|
|
324
|
+
assert.ok(state, "state file should exist");
|
|
325
|
+
assert.equal(state.version, 1);
|
|
326
|
+
assert.equal(state.inputTokens, 50000);
|
|
327
|
+
assert.equal(state.modelWindowSize, 200000);
|
|
328
|
+
assert.ok(Math.abs(state.pct - 25) < 0.0001, `pct ${state.pct} should ≈ 25`);
|
|
329
|
+
assert.equal(state.threshold, "normal");
|
|
330
|
+
assert.equal(state.checkCount, 1);
|
|
331
|
+
assert.equal(state.lastError, null);
|
|
332
|
+
assert.ok(typeof state.timestamp === "string" && state.timestamp.length > 0);
|
|
333
|
+
|
|
334
|
+
assert.equal(sandbox.tmpFileExists(), false, "no leftover .tmp file");
|
|
335
|
+
assert.equal(sandbox.hitCount, 1, "stub server should have been called exactly once");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("E2E 2. above threshold — stdout additionalContext and state reflects 80%", async () => {
|
|
339
|
+
sandbox.writeConfig({ thresholdPct: 75, modelWindowSize: 200000, checkFrequency: 1 });
|
|
340
|
+
const transcriptPath = sandbox.writeTranscript();
|
|
341
|
+
await sandbox.startStub({ inputTokens: 160000 });
|
|
342
|
+
|
|
343
|
+
const { stdout, code } = await sandbox.runHook({
|
|
344
|
+
payload: { session_id: "test-above", transcript_path: transcriptPath },
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
assert.equal(code, 0);
|
|
348
|
+
const parsed = JSON.parse(stdout || "{}");
|
|
349
|
+
assert.deepEqual(parsed, {
|
|
350
|
+
additionalContext:
|
|
351
|
+
"⚠️ Context window at 80.0% of 200000. Run /user:gsd-t-pause to checkpoint and clear before continuing.",
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const state = sandbox.readState();
|
|
355
|
+
assert.ok(state);
|
|
356
|
+
assert.equal(state.inputTokens, 160000);
|
|
357
|
+
assert.equal(state.modelWindowSize, 200000);
|
|
358
|
+
assert.ok(Math.abs(state.pct - 80) < 0.0001, `pct ${state.pct} should ≈ 80`);
|
|
359
|
+
// v3.0.0 three-band (M35): 80% ∈ [70, 85) → warn
|
|
360
|
+
assert.equal(state.threshold, "warn");
|
|
361
|
+
assert.equal(state.checkCount, 1);
|
|
362
|
+
assert.equal(state.lastError, null);
|
|
363
|
+
|
|
364
|
+
assert.equal(sandbox.tmpFileExists(), false);
|
|
365
|
+
assert.equal(sandbox.hitCount, 1);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("E2E 3. API key missing — stdout {}, state has lastError.code='missing_key'", async () => {
|
|
369
|
+
sandbox.writeConfig({ thresholdPct: 75, checkFrequency: 1 });
|
|
370
|
+
const transcriptPath = sandbox.writeTranscript();
|
|
371
|
+
await sandbox.startStub({ inputTokens: 50000 });
|
|
372
|
+
|
|
373
|
+
const { stdout, code } = await sandbox.runHook({
|
|
374
|
+
payload: { session_id: "test-nokey", transcript_path: transcriptPath },
|
|
375
|
+
env: { ANTHROPIC_API_KEY: null }, // explicitly unset
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
assert.equal(code, 0);
|
|
379
|
+
const parsed = JSON.parse(stdout || "{}");
|
|
380
|
+
assert.deepEqual(parsed, {});
|
|
381
|
+
|
|
382
|
+
const state = sandbox.readState();
|
|
383
|
+
assert.ok(state);
|
|
384
|
+
assert.equal(state.checkCount, 1);
|
|
385
|
+
assert.ok(state.lastError && typeof state.lastError === "object");
|
|
386
|
+
assert.equal(state.lastError.code, "missing_key");
|
|
387
|
+
|
|
388
|
+
// API must NOT have been called.
|
|
389
|
+
assert.equal(sandbox.hitCount, 0, "stub server must not be hit when key is missing");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("E2E 4. checkFrequency skip — API not called, checkCount increments", async () => {
|
|
393
|
+
sandbox.writeConfig({ thresholdPct: 75, checkFrequency: 5 });
|
|
394
|
+
const transcriptPath = sandbox.writeTranscript();
|
|
395
|
+
// Pre-seed state so that checkCount goes 3 → 4, which is NOT a multiple of 5.
|
|
396
|
+
sandbox.writeState({ checkCount: 3 });
|
|
397
|
+
await sandbox.startStub({ inputTokens: 50000 });
|
|
398
|
+
|
|
399
|
+
const { stdout, code } = await sandbox.runHook({
|
|
400
|
+
payload: { session_id: "test-skip", transcript_path: transcriptPath },
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
assert.equal(code, 0);
|
|
404
|
+
const parsed = JSON.parse(stdout || "{}");
|
|
405
|
+
assert.deepEqual(parsed, {});
|
|
406
|
+
|
|
407
|
+
const state = sandbox.readState();
|
|
408
|
+
assert.ok(state);
|
|
409
|
+
assert.equal(state.checkCount, 4, "counter increments even on skipped turn");
|
|
410
|
+
// lastError/inputTokens unchanged from seed on skipped turn.
|
|
411
|
+
assert.equal(state.inputTokens, 0);
|
|
412
|
+
|
|
413
|
+
assert.equal(sandbox.hitCount, 0, "stub server must not be hit on skipped turn");
|
|
414
|
+
assert.equal(sandbox.tmpFileExists(), false);
|
|
415
|
+
});
|