Skip to content

Commit 013f02e

Browse files
committed
Rewrite watchdog test to exercise real /handoff command instead of manual state stubs
1 parent af2ff4e commit 013f02e

1 file changed

Lines changed: 104 additions & 19 deletions

File tree

tests/unit/watchdog.test.ts

Lines changed: 104 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { createState } from "../../state.js";
44
import { registerWatchdog } from "../../watchdog.js";
55
import { buildNudge } from "../../watchdog.js";
66
import registerAgenticoding from "../../index.js";
7-
import { createTestPI } from "./helpers.js";
7+
import { registerHandoffCommand } from "../../handoff/command.js";
8+
import { createTestPI, makeReadonlyUICtx } from "./helpers.js";
89

910
test("watchdog records context usage without user notifications", async () => {
1011
const pi = createTestPI();
@@ -22,7 +23,6 @@ test("watchdog records context usage without user notifications", async () => {
2223
},
2324
);
2425

25-
assert.equal(state.lastContextPercent, 70);
2626
assert.deepEqual(notifications, []);
2727
});
2828

@@ -44,10 +44,11 @@ test("context injects watchdog reminder before each LLM call", async () => {
4444
assert.equal(result.messages[1].role, "custom");
4545
assert.equal(result.messages[1].customType, "agenticoding-watchdog");
4646
assert.equal(result.messages[1].display, false);
47-
assert.match(result.messages[1].content, /Context at 70%/);
48-
assert.match(result.messages[1].content, /Active notebook topic: oauth/);
49-
assert.match(result.messages[1].content, /spawn it instead of polluting the parent context/i);
50-
assert.doesNotMatch(result.messages[1].content, /If you're mid-job and still clear|consider a handoff and draft a clear brief for what comes next/i);
47+
assert.match(result.messages[1].content, /70%/);
48+
assert.match(result.messages[1].content, /oauth/);
49+
assert.match(result.messages[1].content, /spawn/i);
50+
assert.match(result.messages[1].content, /parent context/i);
51+
assert.doesNotMatch(result.messages[1].content, /draft a clear brief|what comes next/i);
5152
});
5253

5354

@@ -64,7 +65,9 @@ test("context injects a boundary nudge below 30% after an explicit topic change"
6465
);
6566

6667
assert.equal(result.messages[1].display, false);
67-
assert.match(result.messages[1].content, /Notebook topic changed from oauth to billing/);
68+
assert.match(result.messages[1].content, /oauth/i);
69+
assert.match(result.messages[1].content, /billing/i);
70+
assert.match(result.messages[1].content, /topic changed/i);
6871
});
6972

7073

@@ -82,8 +85,9 @@ test("context injects a no-topic nudge when context is high", async () => {
8285
assert.equal(result.messages[1].role, "custom");
8386
assert.equal(result.messages[1].customType, "agenticoding-watchdog");
8487
assert.equal(result.messages[1].display, false);
85-
assert.match(result.messages[1].content, /No active notebook topic is set/);
86-
assert.match(result.messages[1].content, /Assign a fresh topic in the next clean context after handoff/i);
88+
assert.match(result.messages[1].content, /no active notebook topic/i);
89+
assert.match(result.messages[1].content, /fresh topic/i);
90+
assert.match(result.messages[1].content, /handoff/i);
8791
});
8892

8993

@@ -98,7 +102,9 @@ test("context consumes a boundary hint after the first injected nudge", async ()
98102
{ messages: [{ role: "user", content: "hi", timestamp: 1 }] },
99103
{ getContextUsage: () => ({ percent: 20 }) },
100104
);
101-
assert.match(first.messages[1].content, /Notebook topic changed from oauth to billing/);
105+
assert.match(first.messages[1].content, /oauth/i);
106+
assert.match(first.messages[1].content, /billing/i);
107+
assert.match(first.messages[1].content, /topic changed/i);
102108

103109
const second = await handler(
104110
{ messages: [{ role: "user", content: "hi", timestamp: 2 }] },
@@ -108,11 +114,10 @@ test("context consumes a boundary hint after the first injected nudge", async ()
108114
});
109115

110116

111-
test("buildNudge no longer emits the old percent-only handoff text", () => {
112-
const old = buildNudge({ activeNotebookTopic: "oauth", pendingTopicBoundaryHint: null }, 46);
113-
assert.doesNotMatch(old, /One context, one job\.|If you're mid-job and still clear|consider a handoff and draft a clear brief/i);
114-
assert.match(old, /Active notebook topic: oauth/);
115-
assert.match(old, /prefer spawn/i);
117+
test("buildNudge emits topic and spawn guidance", () => {
118+
const nudge = buildNudge({ activeNotebookTopic: "oauth", pendingTopicBoundaryHint: null }, 46);
119+
assert.match(nudge, /Active notebook topic: oauth/);
120+
assert.match(nudge, /prefer spawn/i);
116121
});
117122

118123

@@ -132,13 +137,18 @@ test("buildNudge handles null percent and boundary hints before topic guidance",
132137
assert.match(noTopic, /No active notebook topic is set/);
133138
});
134139

135-
test("watchdog stays advisory when a requested handoff is not completed", async () => {
140+
test("watchdog stays advisory for a fresh user-requested handoff", async () => {
136141
const pi = createTestPI();
137142
const state = createState();
138-
state.pendingRequestedHandoff = { direction: "implement auth", readonlyBypassActive: false, resumeReadonlyAfterHandoff: false, enforcementAttempts: 0, toolCalled: false };
143+
registerHandoffCommand(pi as any, state);
139144
registerWatchdog(pi as any, state);
140145
const [handler] = pi.handlers.get("agent_end")!;
141146

147+
await pi.commands.get("handoff").handler("implement auth", {
148+
...makeReadonlyUICtx(),
149+
isIdle: () => true,
150+
} as any);
151+
142152
const notifications: string[] = [];
143153
await handler(
144154
{},
@@ -153,7 +163,82 @@ test("watchdog stays advisory when a requested handoff is not completed", async
153163
);
154164

155165
assert.equal(state.pendingRequestedHandoff?.toolCalled, false);
156-
assert.equal(state.pendingRequestedHandoff?.enforcementAttempts, 1);
166+
assert.ok(state.pendingRequestedHandoff, "handoff request should remain active after one turn");
157167
assert.deepEqual(notifications, []);
158-
assert.deepEqual(pi.sentUserMessages, []);
168+
});
169+
170+
test("watchdog auto-cancels a user-requested handoff after enough unanswered turns", async () => {
171+
const pi = createTestPI();
172+
const state = createState();
173+
registerHandoffCommand(pi as any, state);
174+
registerWatchdog(pi as any, state);
175+
const [handler] = pi.handlers.get("agent_end")!;
176+
177+
await pi.commands.get("handoff").handler("implement auth", {
178+
...makeReadonlyUICtx(),
179+
isIdle: () => true,
180+
} as any);
181+
182+
const notifications: unknown[] = [];
183+
const ctx = {
184+
hasUI: true,
185+
ui: { notify: (message: unknown) => notifications.push(message), setStatus: () => {} },
186+
getContextUsage: () => ({ percent: 20 }),
187+
};
188+
189+
for (let i = 0; i < 5; i++) {
190+
await handler({}, ctx);
191+
}
192+
193+
assert.equal(state.pendingRequestedHandoff, null, "pending handoff should be auto-cancelled");
194+
assert.ok(notifications.length > 0, "user should receive a cancellation notification");
195+
assert.match(notifications[0] as string, /cancelled/i, "notification should mention cancellation");
196+
});
197+
198+
// ── Readonly-specific injection contracts ─────────────────────────
199+
200+
test("context injects a readonly-mode nudge after toggle", async () => {
201+
const pi = createTestPI();
202+
registerAgenticoding(pi as any);
203+
await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any);
204+
const [handler] = pi.handlers.get("context")!;
205+
206+
const result = await handler(
207+
{ messages: [{ role: "user", content: "hi", timestamp: 1 }] },
208+
{ getContextUsage: () => null },
209+
);
210+
211+
assert.equal(result.messages.length, 2);
212+
assert.equal(result.messages[1].customType, "agenticoding-readonly-nudge");
213+
assert.match(result.messages[1].content, /readonly/i);
214+
assert.match(result.messages[1].content, /write/i);
215+
assert.match(result.messages[1].content, /edit/i);
216+
assert.match(result.messages[1].content, /handoff/i);
217+
assert.match(result.messages[1].content, /bash/i);
218+
});
219+
220+
test("context injects readonly handoff guidance after explicit user /handoff", async () => {
221+
const pi = createTestPI();
222+
registerAgenticoding(pi as any);
223+
const [handler] = pi.handlers.get("context")!;
224+
await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any);
225+
await handler(
226+
{ messages: [{ role: "user", content: "clear initial readonly nudge", timestamp: 1 }] },
227+
{ getContextUsage: () => null },
228+
);
229+
await pi.commands.get("handoff").handler("continue readonly work", {
230+
...makeReadonlyUICtx(),
231+
isIdle: () => true,
232+
} as any);
233+
234+
const result = await handler(
235+
{ messages: [{ role: "user", content: "hi", timestamp: 2 }] },
236+
{ getContextUsage: () => ({ percent: 70 }) },
237+
);
238+
const watchdogMessage = result.messages.find((message: any) => message.customType === "agenticoding-watchdog");
239+
240+
assert.ok(watchdogMessage, "requested handoff should inject watchdog guidance");
241+
assert.match(watchdogMessage.content, /handoff/i);
242+
assert.match(watchdogMessage.content, /readonly/i);
243+
assert.match(watchdogMessage.content, /resume/i);
159244
});

0 commit comments

Comments
 (0)