opencode-dux 1.0.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/LICENSE +21 -0
- package/README.md +452 -0
- package/dist/agents/descriptions.d.ts +6 -0
- package/dist/agents/designer.d.ts +2 -0
- package/dist/agents/explorer.d.ts +2 -0
- package/dist/agents/fixer.d.ts +2 -0
- package/dist/agents/index.d.ts +22 -0
- package/dist/agents/interpreter.d.ts +2 -0
- package/dist/agents/librarian.d.ts +2 -0
- package/dist/agents/oracle.d.ts +2 -0
- package/dist/agents/orchestrator.d.ts +27 -0
- package/dist/agents/overrides.d.ts +18 -0
- package/dist/agents/prompt-blocks.d.ts +97 -0
- package/dist/agents/steward.d.ts +3 -0
- package/dist/cli/config-io.d.ts +24 -0
- package/dist/cli/config-manager.d.ts +4 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1006 -0
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/mcps.d.ts +13 -0
- package/dist/cli/model-key-normalization.d.ts +1 -0
- package/dist/cli/paths.d.ts +35 -0
- package/dist/cli/providers.d.ts +137 -0
- package/dist/cli/skills.d.ts +22 -0
- package/dist/cli/system.d.ts +5 -0
- package/dist/cli/types.d.ts +38 -0
- package/dist/config/constants.d.ts +12 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/loader.d.ts +40 -0
- package/dist/config/runtime-preset.d.ts +12 -0
- package/dist/config/schema.d.ts +281 -0
- package/dist/config/utils.d.ts +10 -0
- package/dist/discovery/local/types.d.ts +79 -0
- package/dist/discovery/local.d.ts +73 -0
- package/dist/discovery/mcp-servers.d.ts +88 -0
- package/dist/discovery/skills.d.ts +94 -0
- package/dist/hooks/apply-patch/codec.d.ts +7 -0
- package/dist/hooks/apply-patch/errors.d.ts +25 -0
- package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
- package/dist/hooks/apply-patch/index.d.ts +15 -0
- package/dist/hooks/apply-patch/matching.d.ts +26 -0
- package/dist/hooks/apply-patch/operations.d.ts +3 -0
- package/dist/hooks/apply-patch/patch.d.ts +2 -0
- package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
- package/dist/hooks/apply-patch/resolution.d.ts +19 -0
- package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
- package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
- package/dist/hooks/apply-patch/types.d.ts +80 -0
- package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
- package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
- package/dist/hooks/auto-update-checker/index.d.ts +18 -0
- package/dist/hooks/auto-update-checker/types.d.ts +22 -0
- package/dist/hooks/chat-headers.d.ts +16 -0
- package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
- package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
- package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
- package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
- package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
- package/dist/hooks/filter-available-skills/index.d.ts +32 -0
- package/dist/hooks/foreground-fallback/index.d.ts +72 -0
- package/dist/hooks/image-hook.d.ts +5 -0
- package/dist/hooks/index.d.ts +14 -0
- package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
- package/dist/hooks/json-error-recovery/index.d.ts +1 -0
- package/dist/hooks/phase-reminder/index.d.ts +26 -0
- package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
- package/dist/hooks/task-session-manager/index.d.ts +52 -0
- package/dist/hooks/todo-continuation/index.d.ts +53 -0
- package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +31782 -0
- package/dist/mcp/context7.d.ts +6 -0
- package/dist/mcp/grep-app.d.ts +6 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/types.d.ts +12 -0
- package/dist/mcp/websearch.d.ts +9 -0
- package/dist/skills/registry.d.ts +29 -0
- package/dist/subscriptions/accounts-store.d.ts +57 -0
- package/dist/subscriptions/index.d.ts +13 -0
- package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
- package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
- package/dist/subscriptions/types.d.ts +115 -0
- package/dist/subscriptions/usage-service.d.ts +74 -0
- package/dist/tools/ast-grep/cli.d.ts +15 -0
- package/dist/tools/ast-grep/constants.d.ts +25 -0
- package/dist/tools/ast-grep/downloader.d.ts +5 -0
- package/dist/tools/ast-grep/index.d.ts +10 -0
- package/dist/tools/ast-grep/tools.d.ts +3 -0
- package/dist/tools/ast-grep/types.d.ts +30 -0
- package/dist/tools/ast-grep/utils.d.ts +4 -0
- package/dist/tools/delegate.d.ts +14 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/preset-manager.d.ts +27 -0
- package/dist/tools/smartfetch/binary.d.ts +3 -0
- package/dist/tools/smartfetch/cache.d.ts +6 -0
- package/dist/tools/smartfetch/constants.d.ts +12 -0
- package/dist/tools/smartfetch/index.d.ts +3 -0
- package/dist/tools/smartfetch/network.d.ts +38 -0
- package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
- package/dist/tools/smartfetch/tool.d.ts +3 -0
- package/dist/tools/smartfetch/types.d.ts +122 -0
- package/dist/tools/smartfetch/utils.d.ts +18 -0
- package/dist/tui-state.d.ts +168 -0
- package/dist/tui.d.ts +37 -0
- package/dist/tui.js +1896 -0
- package/dist/utils/agent-variant.d.ts +63 -0
- package/dist/utils/compat.d.ts +30 -0
- package/dist/utils/env.d.ts +1 -0
- package/dist/utils/index.d.ts +9 -0
- package/dist/utils/internal-initiator.d.ts +6 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/polling.d.ts +21 -0
- package/dist/utils/session-manager.d.ts +55 -0
- package/dist/utils/session.d.ts +90 -0
- package/dist/utils/subagent-depth.d.ts +35 -0
- package/dist/utils/system-collapse.d.ts +6 -0
- package/dist/utils/task.d.ts +4 -0
- package/dist/utils/zip-extractor.d.ts +1 -0
- package/index.ts +1 -0
- package/opencode-dux.schema.json +634 -0
- package/package.json +103 -0
- package/src/agents/descriptions.ts +55 -0
- package/src/agents/designer.test.ts +86 -0
- package/src/agents/designer.ts +154 -0
- package/src/agents/display-name.test.ts +186 -0
- package/src/agents/explorer.test.ts +79 -0
- package/src/agents/explorer.ts +144 -0
- package/src/agents/fixer.test.ts +79 -0
- package/src/agents/fixer.ts +145 -0
- package/src/agents/index.test.ts +472 -0
- package/src/agents/index.ts +248 -0
- package/src/agents/interpreter.ts +136 -0
- package/src/agents/librarian.test.ts +80 -0
- package/src/agents/librarian.ts +145 -0
- package/src/agents/oracle.test.ts +89 -0
- package/src/agents/oracle.ts +184 -0
- package/src/agents/orchestrator.test.ts +116 -0
- package/src/agents/orchestrator.ts +574 -0
- package/src/agents/overrides.ts +95 -0
- package/src/agents/prompt-blocks.test.ts +114 -0
- package/src/agents/prompt-blocks.ts +640 -0
- package/src/agents/steward.ts +146 -0
- package/src/cli/config-io.test.ts +536 -0
- package/src/cli/config-io.ts +473 -0
- package/src/cli/config-manager.test.ts +141 -0
- package/src/cli/config-manager.ts +4 -0
- package/src/cli/index.ts +88 -0
- package/src/cli/install.ts +282 -0
- package/src/cli/mcps.test.ts +62 -0
- package/src/cli/mcps.ts +39 -0
- package/src/cli/model-key-normalization.test.ts +21 -0
- package/src/cli/model-key-normalization.ts +60 -0
- package/src/cli/paths.test.ts +167 -0
- package/src/cli/paths.ts +144 -0
- package/src/cli/providers.test.ts +118 -0
- package/src/cli/providers.ts +141 -0
- package/src/cli/skills.test.ts +111 -0
- package/src/cli/skills.ts +103 -0
- package/src/cli/system.test.ts +91 -0
- package/src/cli/system.ts +180 -0
- package/src/cli/types.ts +43 -0
- package/src/config/constants.ts +58 -0
- package/src/config/index.ts +4 -0
- package/src/config/loader.test.ts +1194 -0
- package/src/config/loader.ts +269 -0
- package/src/config/model-resolution.test.ts +176 -0
- package/src/config/runtime-preset.test.ts +61 -0
- package/src/config/runtime-preset.ts +37 -0
- package/src/config/schema.ts +248 -0
- package/src/config/utils.test.ts +41 -0
- package/src/config/utils.ts +23 -0
- package/src/discovery/local/types.ts +85 -0
- package/src/discovery/local.ts +322 -0
- package/src/discovery/mcp-servers.ts +804 -0
- package/src/discovery/skills.ts +959 -0
- package/src/hooks/apply-patch/codec.test.ts +184 -0
- package/src/hooks/apply-patch/codec.ts +352 -0
- package/src/hooks/apply-patch/errors.ts +117 -0
- package/src/hooks/apply-patch/execution-context.ts +432 -0
- package/src/hooks/apply-patch/hook.test.ts +768 -0
- package/src/hooks/apply-patch/index.ts +126 -0
- package/src/hooks/apply-patch/matching.test.ts +215 -0
- package/src/hooks/apply-patch/matching.ts +586 -0
- package/src/hooks/apply-patch/operations.test.ts +1535 -0
- package/src/hooks/apply-patch/operations.ts +3 -0
- package/src/hooks/apply-patch/patch.ts +9 -0
- package/src/hooks/apply-patch/prepared-changes.ts +400 -0
- package/src/hooks/apply-patch/resolution.test.ts +420 -0
- package/src/hooks/apply-patch/resolution.ts +437 -0
- package/src/hooks/apply-patch/rewrite.ts +496 -0
- package/src/hooks/apply-patch/test-helpers.ts +52 -0
- package/src/hooks/apply-patch/types.ts +111 -0
- package/src/hooks/auto-update-checker/cache.test.ts +179 -0
- package/src/hooks/auto-update-checker/cache.ts +188 -0
- package/src/hooks/auto-update-checker/checker.test.ts +159 -0
- package/src/hooks/auto-update-checker/checker.ts +308 -0
- package/src/hooks/auto-update-checker/constants.ts +33 -0
- package/src/hooks/auto-update-checker/index.test.ts +282 -0
- package/src/hooks/auto-update-checker/index.ts +225 -0
- package/src/hooks/auto-update-checker/types.ts +26 -0
- package/src/hooks/chat-headers.test.ts +236 -0
- package/src/hooks/chat-headers.ts +97 -0
- package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
- package/src/hooks/context-pressure-reminder/index.ts +137 -0
- package/src/hooks/delegate-task-retry/guidance.ts +41 -0
- package/src/hooks/delegate-task-retry/hook.ts +23 -0
- package/src/hooks/delegate-task-retry/index.test.ts +38 -0
- package/src/hooks/delegate-task-retry/index.ts +7 -0
- package/src/hooks/delegate-task-retry/patterns.ts +79 -0
- package/src/hooks/filter-available-skills/index.test.ts +297 -0
- package/src/hooks/filter-available-skills/index.ts +160 -0
- package/src/hooks/foreground-fallback/index.test.ts +624 -0
- package/src/hooks/foreground-fallback/index.ts +374 -0
- package/src/hooks/image-hook.ts +6 -0
- package/src/hooks/index.ts +17 -0
- package/src/hooks/json-error-recovery/hook.ts +73 -0
- package/src/hooks/json-error-recovery/index.test.ts +111 -0
- package/src/hooks/json-error-recovery/index.ts +6 -0
- package/src/hooks/phase-reminder/index.test.ts +74 -0
- package/src/hooks/phase-reminder/index.ts +85 -0
- package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
- package/src/hooks/post-file-tool-nudge/index.ts +63 -0
- package/src/hooks/task-session-manager/index.test.ts +833 -0
- package/src/hooks/task-session-manager/index.ts +434 -0
- package/src/hooks/todo-continuation/index.test.ts +3026 -0
- package/src/hooks/todo-continuation/index.ts +878 -0
- package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
- package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
- package/src/index.ts +1672 -0
- package/src/mcp/context7.ts +14 -0
- package/src/mcp/grep-app.ts +11 -0
- package/src/mcp/index.test.ts +96 -0
- package/src/mcp/index.ts +66 -0
- package/src/mcp/types.ts +16 -0
- package/src/mcp/websearch.ts +47 -0
- package/src/skills/codemap/README.md +60 -0
- package/src/skills/codemap/SKILL.md +174 -0
- package/src/skills/codemap/scripts/codemap.mjs +483 -0
- package/src/skills/codemap/scripts/codemap.test.ts +129 -0
- package/src/skills/registry.ts +218 -0
- package/src/skills/simplify/README.md +19 -0
- package/src/skills/simplify/SKILL.md +138 -0
- package/src/subscriptions/accounts-store.test.ts +236 -0
- package/src/subscriptions/accounts-store.ts +184 -0
- package/src/subscriptions/index.ts +30 -0
- package/src/subscriptions/neuralwatt-scraper.ts +108 -0
- package/src/subscriptions/opencode-go-scraper.ts +301 -0
- package/src/subscriptions/types.ts +145 -0
- package/src/subscriptions/usage-service.test.ts +202 -0
- package/src/subscriptions/usage-service.ts +651 -0
- package/src/tools/ast-grep/cli.ts +257 -0
- package/src/tools/ast-grep/constants.ts +214 -0
- package/src/tools/ast-grep/downloader.ts +131 -0
- package/src/tools/ast-grep/index.ts +24 -0
- package/src/tools/ast-grep/tools.ts +117 -0
- package/src/tools/ast-grep/types.ts +51 -0
- package/src/tools/ast-grep/utils.ts +126 -0
- package/src/tools/delegate-handoff.test.ts +18 -0
- package/src/tools/delegate.ts +508 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/preset-manager.test.ts +795 -0
- package/src/tools/preset-manager.ts +332 -0
- package/src/tools/smartfetch/binary.ts +58 -0
- package/src/tools/smartfetch/cache.test.ts +34 -0
- package/src/tools/smartfetch/cache.ts +112 -0
- package/src/tools/smartfetch/constants.ts +29 -0
- package/src/tools/smartfetch/index.ts +8 -0
- package/src/tools/smartfetch/network.test.ts +178 -0
- package/src/tools/smartfetch/network.ts +614 -0
- package/src/tools/smartfetch/secondary-model.test.ts +85 -0
- package/src/tools/smartfetch/secondary-model.ts +276 -0
- package/src/tools/smartfetch/tool.test.ts +60 -0
- package/src/tools/smartfetch/tool.ts +832 -0
- package/src/tools/smartfetch/types.ts +135 -0
- package/src/tools/smartfetch/utils.test.ts +24 -0
- package/src/tools/smartfetch/utils.ts +456 -0
- package/src/tui-state.test.ts +867 -0
- package/src/tui-state.ts +1255 -0
- package/src/tui.test.ts +336 -0
- package/src/tui.ts +1539 -0
- package/src/utils/agent-variant.test.ts +244 -0
- package/src/utils/agent-variant.ts +187 -0
- package/src/utils/compat.ts +91 -0
- package/src/utils/env.ts +12 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/internal-initiator.ts +28 -0
- package/src/utils/logger.test.ts +220 -0
- package/src/utils/logger.ts +136 -0
- package/src/utils/polling.test.ts +191 -0
- package/src/utils/polling.ts +67 -0
- package/src/utils/session-manager.test.ts +173 -0
- package/src/utils/session-manager.ts +356 -0
- package/src/utils/session.test.ts +110 -0
- package/src/utils/session.ts +389 -0
- package/src/utils/subagent-depth.test.ts +170 -0
- package/src/utils/subagent-depth.ts +75 -0
- package/src/utils/system-collapse.test.ts +86 -0
- package/src/utils/system-collapse.ts +24 -0
- package/src/utils/task.test.ts +24 -0
- package/src/utils/task.ts +20 -0
- package/src/utils/zip-extractor.ts +102 -0
|
@@ -0,0 +1,3026 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { SLIM_INTERNAL_INITIATOR_MARKER } from '../../utils';
|
|
3
|
+
import { createTodoContinuationHook } from './index';
|
|
4
|
+
import {
|
|
5
|
+
TODO_FINAL_ACTIVE_REMINDER,
|
|
6
|
+
TODO_HYGIENE_REMINDER,
|
|
7
|
+
} from './todo-hygiene';
|
|
8
|
+
|
|
9
|
+
describe('createTodoContinuationHook', () => {
|
|
10
|
+
function createMockContext(overrides?: {
|
|
11
|
+
todoResult?: {
|
|
12
|
+
data?: Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
content: string;
|
|
15
|
+
status: string;
|
|
16
|
+
priority: string;
|
|
17
|
+
}>;
|
|
18
|
+
};
|
|
19
|
+
messagesResult?: {
|
|
20
|
+
data?: Array<{
|
|
21
|
+
info?: { role?: string };
|
|
22
|
+
parts?: Array<{ type?: string; text?: string }>;
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
}) {
|
|
26
|
+
return {
|
|
27
|
+
client: {
|
|
28
|
+
session: {
|
|
29
|
+
todo: mock(async () => overrides?.todoResult ?? { data: [] }),
|
|
30
|
+
messages: mock(async () => overrides?.messagesResult ?? { data: [] }),
|
|
31
|
+
prompt: mock(async () => ({})),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
} as any;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function delay(ms: number): Promise<void> {
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Notification prompts (noReply:true, no marker) fire immediately when
|
|
42
|
+
// scheduling a continuation. These helpers check only for actual
|
|
43
|
+
// continuation prompts (with SLIM_INTERNAL_INITIATOR_MARKER).
|
|
44
|
+
function hasContinuation(m: ReturnType<typeof mock>): boolean {
|
|
45
|
+
return m.mock.calls.some((c: any[]) =>
|
|
46
|
+
(c[0]?.body?.parts as any[])?.some((p: any) =>
|
|
47
|
+
p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
function contCount(m: ReturnType<typeof mock>): number {
|
|
52
|
+
return m.mock.calls.filter((c: any[]) =>
|
|
53
|
+
(c[0]?.body?.parts as any[])?.some((p: any) =>
|
|
54
|
+
p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
|
|
55
|
+
),
|
|
56
|
+
).length;
|
|
57
|
+
}
|
|
58
|
+
function contCall(m: ReturnType<typeof mock>): any[] {
|
|
59
|
+
const call = m.mock.calls.find((c: any[]) =>
|
|
60
|
+
(c[0]?.body?.parts as any[])?.some((p: any) =>
|
|
61
|
+
p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
if (!call) {
|
|
65
|
+
throw new Error('No continuation call found');
|
|
66
|
+
}
|
|
67
|
+
return call;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function userMessages(
|
|
71
|
+
text: string,
|
|
72
|
+
sessionID = 'main1',
|
|
73
|
+
agent?: string,
|
|
74
|
+
parts?: Array<{ type: string; text?: string }>,
|
|
75
|
+
id?: string,
|
|
76
|
+
) {
|
|
77
|
+
return {
|
|
78
|
+
messages: [
|
|
79
|
+
{
|
|
80
|
+
info: { id, role: 'user', agent, sessionID },
|
|
81
|
+
parts: parts ?? [{ type: 'text', text }],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function allMessageText(output: {
|
|
88
|
+
messages: Array<{ parts: Array<{ type?: string; text?: string }> }>;
|
|
89
|
+
}) {
|
|
90
|
+
return output.messages
|
|
91
|
+
.flatMap((message) => message.parts)
|
|
92
|
+
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
|
93
|
+
.map((part) => part.text)
|
|
94
|
+
.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('tool toggle', () => {
|
|
98
|
+
test('calling auto_continue execute with { enabled: true } sets state', async () => {
|
|
99
|
+
const ctx = createMockContext();
|
|
100
|
+
const hook = createTodoContinuationHook(ctx);
|
|
101
|
+
|
|
102
|
+
const result = await hook.tool.auto_continue.execute({ enabled: true });
|
|
103
|
+
|
|
104
|
+
expect(result).toContain('Auto-continue enabled');
|
|
105
|
+
expect(result).toContain('up to 5');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('calling auto_continue execute with { enabled: false } disables', async () => {
|
|
109
|
+
const ctx = createMockContext();
|
|
110
|
+
const hook = createTodoContinuationHook(ctx);
|
|
111
|
+
|
|
112
|
+
const result = await hook.tool.auto_continue.execute({ enabled: false });
|
|
113
|
+
|
|
114
|
+
expect(result).toBe('Auto-continue disabled.');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('todo hygiene routing', () => {
|
|
119
|
+
test('does not inject hygiene reminder for unknown non-orchestrator session', async () => {
|
|
120
|
+
const ctx = createMockContext({
|
|
121
|
+
todoResult: {
|
|
122
|
+
data: [
|
|
123
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const hook = createTodoContinuationHook(ctx);
|
|
128
|
+
const toolOutput = { output: 'task result' };
|
|
129
|
+
|
|
130
|
+
await hook.handleMessagesTransform(
|
|
131
|
+
userMessages('continue previous work', 'sub1', 'explorer'),
|
|
132
|
+
);
|
|
133
|
+
await hook.handleToolExecuteAfter(
|
|
134
|
+
{ tool: 'task', sessionID: 'sub1' },
|
|
135
|
+
toolOutput,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(toolOutput.output).toBe('task result');
|
|
139
|
+
expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('does not expose a system transform handler', async () => {
|
|
143
|
+
const ctx = createMockContext({
|
|
144
|
+
todoResult: {
|
|
145
|
+
data: [
|
|
146
|
+
{
|
|
147
|
+
id: '1',
|
|
148
|
+
content: 'todo1',
|
|
149
|
+
status: 'in_progress',
|
|
150
|
+
priority: 'high',
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const hook = createTodoContinuationHook(ctx);
|
|
156
|
+
|
|
157
|
+
expect('handleChatSystemTransform' in hook).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('injects hygiene reminder into latest user message after todowrite activity', async () => {
|
|
161
|
+
const ctx = createMockContext({
|
|
162
|
+
todoResult: {
|
|
163
|
+
data: [
|
|
164
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const hook = createTodoContinuationHook(ctx);
|
|
169
|
+
const output = userMessages('primera request', 'main1', 'orchestrator');
|
|
170
|
+
const toolOutput = { output: 'read result' };
|
|
171
|
+
|
|
172
|
+
await hook.handleMessagesTransform(output);
|
|
173
|
+
await hook.handleToolExecuteAfter({
|
|
174
|
+
tool: 'todowrite',
|
|
175
|
+
sessionID: 'main1',
|
|
176
|
+
});
|
|
177
|
+
await hook.handleToolExecuteAfter(
|
|
178
|
+
{ tool: 'read', sessionID: 'main1' },
|
|
179
|
+
toolOutput,
|
|
180
|
+
);
|
|
181
|
+
await hook.handleMessagesTransform(output);
|
|
182
|
+
|
|
183
|
+
expect(toolOutput.output).toBe('read result');
|
|
184
|
+
expect(allMessageText(output)).toContain(TODO_HYGIENE_REMINDER);
|
|
185
|
+
expect(allMessageText(output)).toContain(
|
|
186
|
+
'<instruction name="todo_hygiene">',
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('compaction-like transform does not consume pending reminder', async () => {
|
|
191
|
+
const ctx = createMockContext({
|
|
192
|
+
todoResult: {
|
|
193
|
+
data: [
|
|
194
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
const hook = createTodoContinuationHook(ctx);
|
|
199
|
+
const live = userMessages('primera request', 'main1', 'orchestrator');
|
|
200
|
+
const compactionClone = structuredClone(live);
|
|
201
|
+
|
|
202
|
+
await hook.handleMessagesTransform(live);
|
|
203
|
+
await hook.handleToolExecuteAfter({
|
|
204
|
+
tool: 'todowrite',
|
|
205
|
+
sessionID: 'main1',
|
|
206
|
+
});
|
|
207
|
+
await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
208
|
+
|
|
209
|
+
await hook.handleMessagesTransform(compactionClone);
|
|
210
|
+
expect(allMessageText(compactionClone)).toContain(TODO_HYGIENE_REMINDER);
|
|
211
|
+
|
|
212
|
+
await hook.handleMessagesTransform(live);
|
|
213
|
+
expect(allMessageText(live)).toContain(TODO_HYGIENE_REMINDER);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('new request clears stale pending reminder state', async () => {
|
|
217
|
+
const ctx = createMockContext({
|
|
218
|
+
todoResult: {
|
|
219
|
+
data: [
|
|
220
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
const hook = createTodoContinuationHook(ctx);
|
|
225
|
+
const first = userMessages('primera request', 'main1', 'orchestrator');
|
|
226
|
+
const blocked = userMessages(
|
|
227
|
+
'segunda request distinta',
|
|
228
|
+
'main1',
|
|
229
|
+
'orchestrator',
|
|
230
|
+
);
|
|
231
|
+
const allowed = userMessages(
|
|
232
|
+
'segunda request distinta',
|
|
233
|
+
'main1',
|
|
234
|
+
'orchestrator',
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
await hook.handleMessagesTransform(first);
|
|
238
|
+
await hook.handleToolExecuteAfter({
|
|
239
|
+
tool: 'todowrite',
|
|
240
|
+
sessionID: 'main1',
|
|
241
|
+
});
|
|
242
|
+
await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
243
|
+
|
|
244
|
+
await hook.handleMessagesTransform(blocked);
|
|
245
|
+
expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
|
|
246
|
+
|
|
247
|
+
await hook.handleToolExecuteAfter({
|
|
248
|
+
tool: 'todowrite',
|
|
249
|
+
sessionID: 'main1',
|
|
250
|
+
});
|
|
251
|
+
await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
252
|
+
await hook.handleMessagesTransform(allowed);
|
|
253
|
+
|
|
254
|
+
expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('attachment-only requests reset stale state without synthetic text parts', async () => {
|
|
258
|
+
const ctx = createMockContext({
|
|
259
|
+
todoResult: {
|
|
260
|
+
data: [
|
|
261
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
const hook = createTodoContinuationHook(ctx);
|
|
266
|
+
const first = userMessages('primera request', 'main1', 'orchestrator');
|
|
267
|
+
const attachmentOnly = userMessages('', 'main1', 'orchestrator', [
|
|
268
|
+
{ type: 'image' },
|
|
269
|
+
]);
|
|
270
|
+
|
|
271
|
+
await hook.handleMessagesTransform(first);
|
|
272
|
+
await hook.handleToolExecuteAfter({
|
|
273
|
+
tool: 'todowrite',
|
|
274
|
+
sessionID: 'main1',
|
|
275
|
+
});
|
|
276
|
+
await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
277
|
+
|
|
278
|
+
await hook.handleMessagesTransform(attachmentOnly);
|
|
279
|
+
|
|
280
|
+
expect(attachmentOnly.messages[0].parts).toHaveLength(1);
|
|
281
|
+
expect(allMessageText(attachmentOnly)).not.toContain(
|
|
282
|
+
TODO_HYGIENE_REMINDER,
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('falls back to known orchestrator session when transform message lacks sessionID', async () => {
|
|
287
|
+
const ctx = createMockContext({
|
|
288
|
+
todoResult: {
|
|
289
|
+
data: [
|
|
290
|
+
{
|
|
291
|
+
id: '1',
|
|
292
|
+
content: 'todo1',
|
|
293
|
+
status: 'in_progress',
|
|
294
|
+
priority: 'high',
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
const hook = createTodoContinuationHook(ctx);
|
|
300
|
+
const output = {
|
|
301
|
+
messages: [
|
|
302
|
+
{
|
|
303
|
+
info: { role: 'user', agent: 'orchestrator' },
|
|
304
|
+
parts: [{ type: 'text', text: 'new request boundary' }],
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
310
|
+
await hook.handleMessagesTransform(output);
|
|
311
|
+
await hook.handleToolExecuteAfter({
|
|
312
|
+
tool: 'todowrite',
|
|
313
|
+
sessionID: 'main1',
|
|
314
|
+
});
|
|
315
|
+
await hook.handleMessagesTransform(output);
|
|
316
|
+
|
|
317
|
+
expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('does not promote sessions with missing agent metadata to orchestrator', async () => {
|
|
321
|
+
const ctx = createMockContext({
|
|
322
|
+
todoResult: {
|
|
323
|
+
data: [
|
|
324
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
const hook = createTodoContinuationHook(ctx);
|
|
329
|
+
const toolOutput = { output: 'task result' };
|
|
330
|
+
|
|
331
|
+
await hook.handleMessagesTransform(
|
|
332
|
+
userMessages('continue previous work', 'sub1'),
|
|
333
|
+
);
|
|
334
|
+
await hook.handleToolExecuteAfter(
|
|
335
|
+
{ tool: 'task', sessionID: 'sub1' },
|
|
336
|
+
toolOutput,
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
expect(toolOutput.output).toBe('task result');
|
|
340
|
+
expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
|
|
341
|
+
expect(toolOutput.output).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('known orchestrator sessions still process request boundaries when agent metadata is missing', async () => {
|
|
345
|
+
const ctx = createMockContext({
|
|
346
|
+
todoResult: {
|
|
347
|
+
data: [
|
|
348
|
+
{
|
|
349
|
+
id: '1',
|
|
350
|
+
content: 'todo1',
|
|
351
|
+
status: 'in_progress',
|
|
352
|
+
priority: 'high',
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
const hook = createTodoContinuationHook(ctx);
|
|
358
|
+
const output = userMessages('new request boundary', 'main1');
|
|
359
|
+
|
|
360
|
+
hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
361
|
+
await hook.handleMessagesTransform(output);
|
|
362
|
+
await hook.handleToolExecuteAfter({
|
|
363
|
+
tool: 'todowrite',
|
|
364
|
+
sessionID: 'main1',
|
|
365
|
+
});
|
|
366
|
+
await hook.handleMessagesTransform(output);
|
|
367
|
+
|
|
368
|
+
expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('the same user message id consumes pending reminder even if array index shifts', async () => {
|
|
372
|
+
const ctx = createMockContext({
|
|
373
|
+
todoResult: {
|
|
374
|
+
data: [
|
|
375
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
const hook = createTodoContinuationHook(ctx);
|
|
380
|
+
const shifted = {
|
|
381
|
+
messages: [
|
|
382
|
+
{
|
|
383
|
+
info: { role: 'assistant', sessionID: 'main1' },
|
|
384
|
+
parts: [{ type: 'text', text: 'intermediate output' }],
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
info: {
|
|
388
|
+
id: 'u1',
|
|
389
|
+
role: 'user',
|
|
390
|
+
agent: 'orchestrator',
|
|
391
|
+
sessionID: 'main1',
|
|
392
|
+
},
|
|
393
|
+
parts: [{ type: 'text', text: 'request boundary' }],
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
await hook.handleMessagesTransform(
|
|
399
|
+
userMessages(
|
|
400
|
+
'request boundary',
|
|
401
|
+
'main1',
|
|
402
|
+
'orchestrator',
|
|
403
|
+
undefined,
|
|
404
|
+
'u1',
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
await hook.handleToolExecuteAfter({
|
|
408
|
+
tool: 'todowrite',
|
|
409
|
+
sessionID: 'main1',
|
|
410
|
+
});
|
|
411
|
+
await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
412
|
+
await hook.handleMessagesTransform(shifted);
|
|
413
|
+
|
|
414
|
+
expect(allMessageText(shifted)).toContain(TODO_HYGIENE_REMINDER);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('a new user message id resets the request even if text is unchanged', async () => {
|
|
418
|
+
const ctx = createMockContext({
|
|
419
|
+
todoResult: {
|
|
420
|
+
data: [
|
|
421
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
422
|
+
],
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
const hook = createTodoContinuationHook(ctx);
|
|
426
|
+
const blocked = userMessages(
|
|
427
|
+
'same text',
|
|
428
|
+
'main1',
|
|
429
|
+
'orchestrator',
|
|
430
|
+
undefined,
|
|
431
|
+
'u2',
|
|
432
|
+
);
|
|
433
|
+
const allowed = userMessages(
|
|
434
|
+
'same text',
|
|
435
|
+
'main1',
|
|
436
|
+
'orchestrator',
|
|
437
|
+
undefined,
|
|
438
|
+
'u2',
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
await hook.handleMessagesTransform(
|
|
442
|
+
userMessages('same text', 'main1', 'orchestrator', undefined, 'u1'),
|
|
443
|
+
);
|
|
444
|
+
await hook.handleToolExecuteAfter({
|
|
445
|
+
tool: 'todowrite',
|
|
446
|
+
sessionID: 'main1',
|
|
447
|
+
});
|
|
448
|
+
await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
449
|
+
|
|
450
|
+
await hook.handleMessagesTransform(blocked);
|
|
451
|
+
expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
|
|
452
|
+
|
|
453
|
+
await hook.handleToolExecuteAfter({
|
|
454
|
+
tool: 'todowrite',
|
|
455
|
+
sessionID: 'main1',
|
|
456
|
+
});
|
|
457
|
+
await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
458
|
+
await hook.handleMessagesTransform(allowed);
|
|
459
|
+
|
|
460
|
+
expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test('a repeated text without message ids resets when a later user turn appears', async () => {
|
|
464
|
+
const ctx = createMockContext({
|
|
465
|
+
todoResult: {
|
|
466
|
+
data: [
|
|
467
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
468
|
+
],
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
const hook = createTodoContinuationHook(ctx);
|
|
472
|
+
const blocked = {
|
|
473
|
+
messages: [
|
|
474
|
+
{
|
|
475
|
+
info: { role: 'user', agent: 'orchestrator', sessionID: 'main1' },
|
|
476
|
+
parts: [{ type: 'text', text: 'same text' }],
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
info: { role: 'assistant', sessionID: 'main1' },
|
|
480
|
+
parts: [{ type: 'text', text: 'intermediate output' }],
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
info: { role: 'user', agent: 'orchestrator', sessionID: 'main1' },
|
|
484
|
+
parts: [{ type: 'text', text: 'same text' }],
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
};
|
|
488
|
+
const allowed = structuredClone(blocked);
|
|
489
|
+
|
|
490
|
+
await hook.handleMessagesTransform(
|
|
491
|
+
userMessages('same text', 'main1', 'orchestrator'),
|
|
492
|
+
);
|
|
493
|
+
await hook.handleToolExecuteAfter({
|
|
494
|
+
tool: 'todowrite',
|
|
495
|
+
sessionID: 'main1',
|
|
496
|
+
});
|
|
497
|
+
await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
498
|
+
|
|
499
|
+
await hook.handleMessagesTransform(blocked);
|
|
500
|
+
expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
|
|
501
|
+
|
|
502
|
+
await hook.handleToolExecuteAfter({
|
|
503
|
+
tool: 'todowrite',
|
|
504
|
+
sessionID: 'main1',
|
|
505
|
+
});
|
|
506
|
+
await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
507
|
+
await hook.handleMessagesTransform(allowed);
|
|
508
|
+
|
|
509
|
+
expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test('messages without inferable sessionID clear stale state for known orchestrators', async () => {
|
|
513
|
+
const ctx = createMockContext({
|
|
514
|
+
todoResult: {
|
|
515
|
+
data: [
|
|
516
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
517
|
+
],
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
const hook = createTodoContinuationHook(ctx);
|
|
521
|
+
const unknown = {
|
|
522
|
+
messages: [
|
|
523
|
+
{
|
|
524
|
+
info: { role: 'user', agent: 'orchestrator' },
|
|
525
|
+
parts: [{ type: 'text', text: 'boundary without session id' }],
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
531
|
+
hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
|
|
532
|
+
await hook.handleMessagesTransform(
|
|
533
|
+
userMessages('first request', 'main1', 'orchestrator', undefined, 'u1'),
|
|
534
|
+
);
|
|
535
|
+
await hook.handleToolExecuteAfter({
|
|
536
|
+
tool: 'todowrite',
|
|
537
|
+
sessionID: 'main1',
|
|
538
|
+
});
|
|
539
|
+
await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
540
|
+
|
|
541
|
+
await hook.handleMessagesTransform(unknown);
|
|
542
|
+
|
|
543
|
+
expect(allMessageText(unknown)).not.toContain(TODO_HYGIENE_REMINDER);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('does not inject from continuation-like wording alone', async () => {
|
|
547
|
+
const ctx = createMockContext({
|
|
548
|
+
todoResult: {
|
|
549
|
+
data: [
|
|
550
|
+
{
|
|
551
|
+
id: '1',
|
|
552
|
+
content: 'todo1',
|
|
553
|
+
status: 'in_progress',
|
|
554
|
+
priority: 'high',
|
|
555
|
+
},
|
|
556
|
+
],
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
const hook = createTodoContinuationHook(ctx);
|
|
560
|
+
const toolOutput = { output: 'read result' };
|
|
561
|
+
|
|
562
|
+
await hook.handleMessagesTransform(
|
|
563
|
+
userMessages(
|
|
564
|
+
'sigue este formato pero empieza de cero',
|
|
565
|
+
'main1',
|
|
566
|
+
'orchestrator',
|
|
567
|
+
),
|
|
568
|
+
);
|
|
569
|
+
await hook.handleToolExecuteAfter(
|
|
570
|
+
{ tool: 'read', sessionID: 'main1' },
|
|
571
|
+
toolOutput,
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
expect(toolOutput.output).toBe('read result');
|
|
575
|
+
expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
|
|
576
|
+
expect(toolOutput.output).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('rearms on activity after todowrite even if request wording is continuation-like', async () => {
|
|
580
|
+
const ctx = createMockContext({
|
|
581
|
+
todoResult: {
|
|
582
|
+
data: [
|
|
583
|
+
{
|
|
584
|
+
id: '1',
|
|
585
|
+
content: 'todo1',
|
|
586
|
+
status: 'in_progress',
|
|
587
|
+
priority: 'high',
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
const hook = createTodoContinuationHook(ctx);
|
|
593
|
+
const output = userMessages(
|
|
594
|
+
'finish the previous work',
|
|
595
|
+
'main1',
|
|
596
|
+
'orchestrator',
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
await hook.handleMessagesTransform(output);
|
|
600
|
+
await hook.handleToolExecuteAfter({
|
|
601
|
+
tool: 'todowrite',
|
|
602
|
+
sessionID: 'main1',
|
|
603
|
+
});
|
|
604
|
+
await hook.handleMessagesTransform(output);
|
|
605
|
+
|
|
606
|
+
expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test('final active todo after todowrite uses the stronger finishing reminder', async () => {
|
|
610
|
+
const ctx = createMockContext({
|
|
611
|
+
todoResult: {
|
|
612
|
+
data: [
|
|
613
|
+
{
|
|
614
|
+
id: '1',
|
|
615
|
+
content: 'todo1',
|
|
616
|
+
status: 'in_progress',
|
|
617
|
+
priority: 'high',
|
|
618
|
+
},
|
|
619
|
+
],
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
const hook = createTodoContinuationHook(ctx);
|
|
623
|
+
const output = userMessages('haz esto', 'main1', 'orchestrator');
|
|
624
|
+
|
|
625
|
+
await hook.handleMessagesTransform(output);
|
|
626
|
+
await hook.handleToolExecuteAfter({
|
|
627
|
+
tool: 'todowrite',
|
|
628
|
+
sessionID: 'main1',
|
|
629
|
+
});
|
|
630
|
+
await hook.handleMessagesTransform(output);
|
|
631
|
+
|
|
632
|
+
expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
|
|
633
|
+
expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
describe('continuation scheduling', () => {
|
|
638
|
+
test('session idle + enabled + incomplete todos → schedules continuation', async () => {
|
|
639
|
+
const ctx = createMockContext({
|
|
640
|
+
todoResult: {
|
|
641
|
+
data: [
|
|
642
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
643
|
+
{ id: '2', content: 'todo2', status: 'completed', priority: 'low' },
|
|
644
|
+
],
|
|
645
|
+
},
|
|
646
|
+
messagesResult: {
|
|
647
|
+
data: [
|
|
648
|
+
{
|
|
649
|
+
info: { role: 'assistant' },
|
|
650
|
+
parts: [{ type: 'text', text: 'Here is the result' }],
|
|
651
|
+
},
|
|
652
|
+
],
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
656
|
+
maxContinuations: 5,
|
|
657
|
+
cooldownMs: 50,
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
// Enable auto-continue
|
|
661
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
662
|
+
|
|
663
|
+
// Fire session.idle event
|
|
664
|
+
await hook.handleEvent({
|
|
665
|
+
event: {
|
|
666
|
+
type: 'session.idle',
|
|
667
|
+
properties: { sessionID: 'session-123' },
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Wait for cooldown
|
|
672
|
+
await delay(60);
|
|
673
|
+
|
|
674
|
+
// Verify session.prompt was called with continuation prompt
|
|
675
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
676
|
+
const promptCall = contCall(ctx.client.session.prompt);
|
|
677
|
+
expect(promptCall[0].path.id).toBe('session-123');
|
|
678
|
+
expect(promptCall[0].body.parts[0].text).toContain(
|
|
679
|
+
'[Auto-continue: enabled - there are incomplete todos remaining.',
|
|
680
|
+
);
|
|
681
|
+
expect(promptCall[0].body.parts[0].text).toContain(
|
|
682
|
+
SLIM_INTERNAL_INITIATOR_MARKER,
|
|
683
|
+
);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test('disabled → no continuation', async () => {
|
|
687
|
+
const ctx = createMockContext({
|
|
688
|
+
todoResult: {
|
|
689
|
+
data: [
|
|
690
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
691
|
+
],
|
|
692
|
+
},
|
|
693
|
+
messagesResult: {
|
|
694
|
+
data: [
|
|
695
|
+
{
|
|
696
|
+
info: { role: 'assistant' },
|
|
697
|
+
parts: [{ type: 'text', text: 'Done' }],
|
|
698
|
+
},
|
|
699
|
+
],
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
703
|
+
|
|
704
|
+
// Do NOT enable auto-continue
|
|
705
|
+
|
|
706
|
+
// Fire session.idle event
|
|
707
|
+
await hook.handleEvent({
|
|
708
|
+
event: {
|
|
709
|
+
type: 'session.idle',
|
|
710
|
+
properties: { sessionID: 'session-123' },
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// Wait for cooldown
|
|
715
|
+
await delay(60);
|
|
716
|
+
|
|
717
|
+
// Verify session.prompt was NOT called
|
|
718
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test('last message is a question → skip', async () => {
|
|
722
|
+
const ctx = createMockContext({
|
|
723
|
+
todoResult: {
|
|
724
|
+
data: [
|
|
725
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
726
|
+
],
|
|
727
|
+
},
|
|
728
|
+
messagesResult: {
|
|
729
|
+
data: [
|
|
730
|
+
{
|
|
731
|
+
info: { role: 'assistant' },
|
|
732
|
+
parts: [
|
|
733
|
+
{ type: 'text', text: 'Should I proceed with the next step?' },
|
|
734
|
+
],
|
|
735
|
+
},
|
|
736
|
+
],
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
740
|
+
|
|
741
|
+
// Enable auto-continue
|
|
742
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
743
|
+
|
|
744
|
+
// Fire session.idle event
|
|
745
|
+
await hook.handleEvent({
|
|
746
|
+
event: {
|
|
747
|
+
type: 'session.idle',
|
|
748
|
+
properties: { sessionID: 'session-123' },
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
// Wait for cooldown
|
|
753
|
+
await delay(60);
|
|
754
|
+
|
|
755
|
+
// Verify continuation NOT scheduled
|
|
756
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test('question detection with question mark → skip', async () => {
|
|
760
|
+
const ctx = createMockContext({
|
|
761
|
+
todoResult: {
|
|
762
|
+
data: [
|
|
763
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
764
|
+
],
|
|
765
|
+
},
|
|
766
|
+
messagesResult: {
|
|
767
|
+
data: [
|
|
768
|
+
{
|
|
769
|
+
info: { role: 'assistant' },
|
|
770
|
+
parts: [{ type: 'text', text: 'Ready to continue?' }],
|
|
771
|
+
},
|
|
772
|
+
],
|
|
773
|
+
},
|
|
774
|
+
});
|
|
775
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
776
|
+
|
|
777
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
778
|
+
|
|
779
|
+
await hook.handleEvent({
|
|
780
|
+
event: {
|
|
781
|
+
type: 'session.idle',
|
|
782
|
+
properties: { sessionID: 'session-123' },
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
await delay(60);
|
|
787
|
+
|
|
788
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test('question detection with "would you like" phrase → skip', async () => {
|
|
792
|
+
const ctx = createMockContext({
|
|
793
|
+
todoResult: {
|
|
794
|
+
data: [
|
|
795
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
796
|
+
],
|
|
797
|
+
},
|
|
798
|
+
messagesResult: {
|
|
799
|
+
data: [
|
|
800
|
+
{
|
|
801
|
+
info: { role: 'assistant' },
|
|
802
|
+
parts: [
|
|
803
|
+
{
|
|
804
|
+
type: 'text',
|
|
805
|
+
text: 'Would you like me to proceed?',
|
|
806
|
+
},
|
|
807
|
+
],
|
|
808
|
+
},
|
|
809
|
+
],
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
813
|
+
|
|
814
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
815
|
+
|
|
816
|
+
await hook.handleEvent({
|
|
817
|
+
event: {
|
|
818
|
+
type: 'session.idle',
|
|
819
|
+
properties: { sessionID: 'session-123' },
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
await delay(60);
|
|
824
|
+
|
|
825
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test('max continuations reached → skip', async () => {
|
|
829
|
+
const ctx = createMockContext({
|
|
830
|
+
todoResult: {
|
|
831
|
+
data: [
|
|
832
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
833
|
+
],
|
|
834
|
+
},
|
|
835
|
+
messagesResult: {
|
|
836
|
+
data: [
|
|
837
|
+
{
|
|
838
|
+
info: { role: 'assistant' },
|
|
839
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
840
|
+
},
|
|
841
|
+
],
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
845
|
+
maxContinuations: 2,
|
|
846
|
+
cooldownMs: 50,
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
850
|
+
|
|
851
|
+
// Fire idle events up to maxContinuations
|
|
852
|
+
for (let i = 0; i < 2; i++) {
|
|
853
|
+
await hook.handleEvent({
|
|
854
|
+
event: {
|
|
855
|
+
type: 'session.idle',
|
|
856
|
+
properties: { sessionID: 'session-123' },
|
|
857
|
+
},
|
|
858
|
+
});
|
|
859
|
+
await delay(60);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Reset mock for the 3rd attempt
|
|
863
|
+
ctx.client.session.prompt.mockClear();
|
|
864
|
+
|
|
865
|
+
// On the N+1th idle, verify no continuation scheduled
|
|
866
|
+
await hook.handleEvent({
|
|
867
|
+
event: {
|
|
868
|
+
type: 'session.idle',
|
|
869
|
+
properties: { sessionID: 'session-123' },
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
await delay(60);
|
|
874
|
+
|
|
875
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test('abort suppress window → skip', async () => {
|
|
879
|
+
const ctx = createMockContext({
|
|
880
|
+
todoResult: {
|
|
881
|
+
data: [
|
|
882
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
883
|
+
],
|
|
884
|
+
},
|
|
885
|
+
messagesResult: {
|
|
886
|
+
data: [
|
|
887
|
+
{
|
|
888
|
+
info: { role: 'assistant' },
|
|
889
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
890
|
+
},
|
|
891
|
+
],
|
|
892
|
+
},
|
|
893
|
+
});
|
|
894
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
895
|
+
|
|
896
|
+
// Seed orchestrator session
|
|
897
|
+
await hook.handleEvent({
|
|
898
|
+
event: {
|
|
899
|
+
type: 'session.idle',
|
|
900
|
+
properties: { sessionID: 'session-123' },
|
|
901
|
+
},
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
905
|
+
|
|
906
|
+
// Fire session.error with MessageAbortedError
|
|
907
|
+
await hook.handleEvent({
|
|
908
|
+
event: {
|
|
909
|
+
type: 'session.error',
|
|
910
|
+
properties: {
|
|
911
|
+
sessionID: 'session-123',
|
|
912
|
+
error: { name: 'MessageAbortedError' },
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
// Immediately fire session.idle
|
|
918
|
+
await hook.handleEvent({
|
|
919
|
+
event: {
|
|
920
|
+
type: 'session.idle',
|
|
921
|
+
properties: { sessionID: 'session-123' },
|
|
922
|
+
},
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// Wait less than suppress window (5s) - just enough to verify it's working
|
|
926
|
+
await delay(100);
|
|
927
|
+
|
|
928
|
+
// Verify no continuation within suppress window
|
|
929
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
test('session busy → cancel pending timer', async () => {
|
|
933
|
+
const ctx = createMockContext({
|
|
934
|
+
todoResult: {
|
|
935
|
+
data: [
|
|
936
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
937
|
+
],
|
|
938
|
+
},
|
|
939
|
+
messagesResult: {
|
|
940
|
+
data: [
|
|
941
|
+
{
|
|
942
|
+
info: { role: 'assistant' },
|
|
943
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
944
|
+
},
|
|
945
|
+
],
|
|
946
|
+
},
|
|
947
|
+
});
|
|
948
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
949
|
+
maxContinuations: 5,
|
|
950
|
+
cooldownMs: 500,
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
954
|
+
|
|
955
|
+
// Schedule a continuation
|
|
956
|
+
await hook.handleEvent({
|
|
957
|
+
event: {
|
|
958
|
+
type: 'session.idle',
|
|
959
|
+
properties: { sessionID: 'session-123' },
|
|
960
|
+
},
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
// After the notification grace but before cooldown expires, fire busy.
|
|
964
|
+
await delay(300);
|
|
965
|
+
await hook.handleEvent({
|
|
966
|
+
event: {
|
|
967
|
+
type: 'session.status',
|
|
968
|
+
properties: {
|
|
969
|
+
sessionID: 'session-123',
|
|
970
|
+
status: { type: 'busy' },
|
|
971
|
+
},
|
|
972
|
+
},
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
// Advance past original cooldown
|
|
976
|
+
await delay(250);
|
|
977
|
+
|
|
978
|
+
// Verify timer was cancelled and prompt NOT called
|
|
979
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
test('sub-agent session.busy does NOT cancel orchestrator timer', async () => {
|
|
983
|
+
const ctx = createMockContext({
|
|
984
|
+
todoResult: {
|
|
985
|
+
data: [
|
|
986
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
987
|
+
],
|
|
988
|
+
},
|
|
989
|
+
messagesResult: {
|
|
990
|
+
data: [
|
|
991
|
+
{
|
|
992
|
+
info: { role: 'assistant' },
|
|
993
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
994
|
+
},
|
|
995
|
+
],
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
999
|
+
maxContinuations: 5,
|
|
1000
|
+
cooldownMs: 100,
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1004
|
+
|
|
1005
|
+
// Schedule a continuation for orchestrator session
|
|
1006
|
+
await hook.handleEvent({
|
|
1007
|
+
event: {
|
|
1008
|
+
type: 'session.idle',
|
|
1009
|
+
properties: { sessionID: 'session-123' },
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// A sub-agent (different session) goes busy
|
|
1014
|
+
await delay(50);
|
|
1015
|
+
await hook.handleEvent({
|
|
1016
|
+
event: {
|
|
1017
|
+
type: 'session.status',
|
|
1018
|
+
properties: {
|
|
1019
|
+
sessionID: 'sub-agent-456',
|
|
1020
|
+
status: { type: 'busy' },
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
// Advance past original cooldown
|
|
1026
|
+
await delay(250);
|
|
1027
|
+
|
|
1028
|
+
// Orchestrator timer should still fire - prompt was called
|
|
1029
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test('all todos complete → skip', async () => {
|
|
1033
|
+
const ctx = createMockContext({
|
|
1034
|
+
todoResult: {
|
|
1035
|
+
data: [
|
|
1036
|
+
{
|
|
1037
|
+
id: '1',
|
|
1038
|
+
content: 'todo1',
|
|
1039
|
+
status: 'completed',
|
|
1040
|
+
priority: 'high',
|
|
1041
|
+
},
|
|
1042
|
+
{ id: '2', content: 'todo2', status: 'cancelled', priority: 'low' },
|
|
1043
|
+
],
|
|
1044
|
+
},
|
|
1045
|
+
messagesResult: {
|
|
1046
|
+
data: [
|
|
1047
|
+
{
|
|
1048
|
+
info: { role: 'assistant' },
|
|
1049
|
+
parts: [{ type: 'text', text: 'All done' }],
|
|
1050
|
+
},
|
|
1051
|
+
],
|
|
1052
|
+
},
|
|
1053
|
+
});
|
|
1054
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
1055
|
+
|
|
1056
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1057
|
+
|
|
1058
|
+
await hook.handleEvent({
|
|
1059
|
+
event: {
|
|
1060
|
+
type: 'session.idle',
|
|
1061
|
+
properties: { sessionID: 'session-123' },
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
await delay(60);
|
|
1066
|
+
|
|
1067
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test('non-orchestrator session → skip', async () => {
|
|
1071
|
+
const ctx = createMockContext({
|
|
1072
|
+
todoResult: {
|
|
1073
|
+
data: [
|
|
1074
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
1075
|
+
],
|
|
1076
|
+
},
|
|
1077
|
+
messagesResult: {
|
|
1078
|
+
data: [
|
|
1079
|
+
{
|
|
1080
|
+
info: { role: 'assistant' },
|
|
1081
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
1082
|
+
},
|
|
1083
|
+
],
|
|
1084
|
+
},
|
|
1085
|
+
});
|
|
1086
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
1087
|
+
|
|
1088
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1089
|
+
|
|
1090
|
+
// First idle from session A (becomes orchestrator)
|
|
1091
|
+
await hook.handleEvent({
|
|
1092
|
+
event: {
|
|
1093
|
+
type: 'session.idle',
|
|
1094
|
+
properties: { sessionID: 'session-A' },
|
|
1095
|
+
},
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
await delay(60);
|
|
1099
|
+
|
|
1100
|
+
// Verify prompt was called for session A
|
|
1101
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
1102
|
+
|
|
1103
|
+
// Reset mock
|
|
1104
|
+
ctx.client.session.prompt.mockClear();
|
|
1105
|
+
|
|
1106
|
+
// Second idle from session B (different sessionID)
|
|
1107
|
+
await hook.handleEvent({
|
|
1108
|
+
event: {
|
|
1109
|
+
type: 'session.idle',
|
|
1110
|
+
properties: { sessionID: 'session-B' },
|
|
1111
|
+
},
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
await delay(60);
|
|
1115
|
+
|
|
1116
|
+
// Verify no continuation for session B
|
|
1117
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
test('cooldownMs from config', async () => {
|
|
1121
|
+
const customCooldownMs = 150;
|
|
1122
|
+
const ctx = createMockContext({
|
|
1123
|
+
todoResult: {
|
|
1124
|
+
data: [
|
|
1125
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
1126
|
+
],
|
|
1127
|
+
},
|
|
1128
|
+
messagesResult: {
|
|
1129
|
+
data: [
|
|
1130
|
+
{
|
|
1131
|
+
info: { role: 'assistant' },
|
|
1132
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
1133
|
+
},
|
|
1134
|
+
],
|
|
1135
|
+
},
|
|
1136
|
+
});
|
|
1137
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
1138
|
+
maxContinuations: 5,
|
|
1139
|
+
cooldownMs: customCooldownMs,
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1143
|
+
|
|
1144
|
+
await hook.handleEvent({
|
|
1145
|
+
event: {
|
|
1146
|
+
type: 'session.idle',
|
|
1147
|
+
properties: { sessionID: 'session-123' },
|
|
1148
|
+
},
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
// Advance timer by well under the custom cooldown to avoid timer jitter
|
|
1152
|
+
await delay(60);
|
|
1153
|
+
|
|
1154
|
+
// Verify prompt not called yet
|
|
1155
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
1156
|
+
|
|
1157
|
+
// Advance timer past the configured cooldown
|
|
1158
|
+
await delay(100);
|
|
1159
|
+
|
|
1160
|
+
// Now prompt should be called
|
|
1161
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
1162
|
+
});
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
describe('event handling - session.error', () => {
|
|
1166
|
+
test('MessageAbortedError sets suppress window', async () => {
|
|
1167
|
+
const ctx = createMockContext({
|
|
1168
|
+
todoResult: {
|
|
1169
|
+
data: [
|
|
1170
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
1171
|
+
],
|
|
1172
|
+
},
|
|
1173
|
+
messagesResult: {
|
|
1174
|
+
data: [
|
|
1175
|
+
{
|
|
1176
|
+
info: { role: 'assistant' },
|
|
1177
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
1178
|
+
},
|
|
1179
|
+
],
|
|
1180
|
+
},
|
|
1181
|
+
});
|
|
1182
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
1183
|
+
|
|
1184
|
+
// Seed orchestrator session
|
|
1185
|
+
await hook.handleEvent({
|
|
1186
|
+
event: {
|
|
1187
|
+
type: 'session.idle',
|
|
1188
|
+
properties: { sessionID: 'session-123' },
|
|
1189
|
+
},
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1193
|
+
|
|
1194
|
+
// Fire session.error with MessageAbortedError
|
|
1195
|
+
await hook.handleEvent({
|
|
1196
|
+
event: {
|
|
1197
|
+
type: 'session.error',
|
|
1198
|
+
properties: {
|
|
1199
|
+
sessionID: 'session-123',
|
|
1200
|
+
error: { name: 'MessageAbortedError' },
|
|
1201
|
+
},
|
|
1202
|
+
},
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
await hook.handleEvent({
|
|
1206
|
+
event: {
|
|
1207
|
+
type: 'session.idle',
|
|
1208
|
+
properties: { sessionID: 'session-123' },
|
|
1209
|
+
},
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
// Wait less than suppress window
|
|
1213
|
+
await delay(100);
|
|
1214
|
+
|
|
1215
|
+
// Verify no continuation within suppress window
|
|
1216
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
test('AbortError sets suppress window', async () => {
|
|
1220
|
+
const ctx = createMockContext({
|
|
1221
|
+
todoResult: {
|
|
1222
|
+
data: [
|
|
1223
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
1224
|
+
],
|
|
1225
|
+
},
|
|
1226
|
+
messagesResult: {
|
|
1227
|
+
data: [
|
|
1228
|
+
{
|
|
1229
|
+
info: { role: 'assistant' },
|
|
1230
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
1231
|
+
},
|
|
1232
|
+
],
|
|
1233
|
+
},
|
|
1234
|
+
});
|
|
1235
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
1236
|
+
|
|
1237
|
+
// Seed orchestrator session (disabled, so no continuation fires)
|
|
1238
|
+
await hook.handleEvent({
|
|
1239
|
+
event: {
|
|
1240
|
+
type: 'session.idle',
|
|
1241
|
+
properties: { sessionID: 'session-123' },
|
|
1242
|
+
},
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1246
|
+
|
|
1247
|
+
await hook.handleEvent({
|
|
1248
|
+
event: {
|
|
1249
|
+
type: 'session.error',
|
|
1250
|
+
properties: {
|
|
1251
|
+
sessionID: 'session-123',
|
|
1252
|
+
error: { name: 'AbortError' },
|
|
1253
|
+
},
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
await hook.handleEvent({
|
|
1258
|
+
event: {
|
|
1259
|
+
type: 'session.idle',
|
|
1260
|
+
properties: { sessionID: 'session-123' },
|
|
1261
|
+
},
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
// Wait less than suppress window
|
|
1265
|
+
await delay(100);
|
|
1266
|
+
|
|
1267
|
+
// Verify no continuation within suppress window
|
|
1268
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
test('other errors do not set suppress window', async () => {
|
|
1272
|
+
const ctx = createMockContext({
|
|
1273
|
+
todoResult: {
|
|
1274
|
+
data: [
|
|
1275
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
1276
|
+
],
|
|
1277
|
+
},
|
|
1278
|
+
messagesResult: {
|
|
1279
|
+
data: [
|
|
1280
|
+
{
|
|
1281
|
+
info: { role: 'assistant' },
|
|
1282
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
1283
|
+
},
|
|
1284
|
+
],
|
|
1285
|
+
},
|
|
1286
|
+
});
|
|
1287
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
1288
|
+
|
|
1289
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1290
|
+
|
|
1291
|
+
await hook.handleEvent({
|
|
1292
|
+
event: {
|
|
1293
|
+
type: 'session.error',
|
|
1294
|
+
properties: {
|
|
1295
|
+
sessionID: 'session-123',
|
|
1296
|
+
error: { name: 'NetworkError' },
|
|
1297
|
+
},
|
|
1298
|
+
},
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
await hook.handleEvent({
|
|
1302
|
+
event: {
|
|
1303
|
+
type: 'session.idle',
|
|
1304
|
+
properties: { sessionID: 'session-123' },
|
|
1305
|
+
},
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
await delay(60);
|
|
1309
|
+
|
|
1310
|
+
// Prompt should be called immediately (no suppress window)
|
|
1311
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
1312
|
+
});
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
describe('event handling - session.deleted', () => {
|
|
1316
|
+
test('clears pending timer on session delete', async () => {
|
|
1317
|
+
const ctx = createMockContext({
|
|
1318
|
+
todoResult: {
|
|
1319
|
+
data: [
|
|
1320
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
1321
|
+
],
|
|
1322
|
+
},
|
|
1323
|
+
messagesResult: {
|
|
1324
|
+
data: [
|
|
1325
|
+
{
|
|
1326
|
+
info: { role: 'assistant' },
|
|
1327
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
1328
|
+
},
|
|
1329
|
+
],
|
|
1330
|
+
},
|
|
1331
|
+
});
|
|
1332
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
1333
|
+
maxContinuations: 5,
|
|
1334
|
+
cooldownMs: 100,
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1338
|
+
|
|
1339
|
+
// Schedule continuation
|
|
1340
|
+
await hook.handleEvent({
|
|
1341
|
+
event: {
|
|
1342
|
+
type: 'session.idle',
|
|
1343
|
+
properties: { sessionID: 'session-123' },
|
|
1344
|
+
},
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
// Delete session before timer fires
|
|
1348
|
+
await delay(50);
|
|
1349
|
+
await hook.handleEvent({
|
|
1350
|
+
event: {
|
|
1351
|
+
type: 'session.deleted',
|
|
1352
|
+
properties: {
|
|
1353
|
+
sessionID: 'session-123',
|
|
1354
|
+
},
|
|
1355
|
+
},
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
// Advance past original cooldown
|
|
1359
|
+
await delay(250);
|
|
1360
|
+
|
|
1361
|
+
// Verify timer was cancelled and prompt NOT called
|
|
1362
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
test('sub-agent session.deleted does NOT cancel orchestrator timer', async () => {
|
|
1366
|
+
const ctx = createMockContext({
|
|
1367
|
+
todoResult: {
|
|
1368
|
+
data: [
|
|
1369
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
1370
|
+
],
|
|
1371
|
+
},
|
|
1372
|
+
messagesResult: {
|
|
1373
|
+
data: [
|
|
1374
|
+
{
|
|
1375
|
+
info: { role: 'assistant' },
|
|
1376
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
1377
|
+
},
|
|
1378
|
+
],
|
|
1379
|
+
},
|
|
1380
|
+
});
|
|
1381
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
1382
|
+
maxContinuations: 5,
|
|
1383
|
+
cooldownMs: 100,
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1387
|
+
|
|
1388
|
+
// Schedule continuation for orchestrator session
|
|
1389
|
+
await hook.handleEvent({
|
|
1390
|
+
event: {
|
|
1391
|
+
type: 'session.idle',
|
|
1392
|
+
properties: { sessionID: 'session-123' },
|
|
1393
|
+
},
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
// A sub-agent (different session) gets deleted
|
|
1397
|
+
await delay(50);
|
|
1398
|
+
await hook.handleEvent({
|
|
1399
|
+
event: {
|
|
1400
|
+
type: 'session.deleted',
|
|
1401
|
+
properties: {
|
|
1402
|
+
sessionID: 'sub-agent-456',
|
|
1403
|
+
},
|
|
1404
|
+
},
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
// Advance past original cooldown
|
|
1408
|
+
await delay(250);
|
|
1409
|
+
|
|
1410
|
+
// Orchestrator timer should still fire - prompt was called
|
|
1411
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
test('resets orchestrator session when deleted session matches', async () => {
|
|
1415
|
+
const ctx = createMockContext({
|
|
1416
|
+
todoResult: {
|
|
1417
|
+
data: [
|
|
1418
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
1419
|
+
],
|
|
1420
|
+
},
|
|
1421
|
+
messagesResult: {
|
|
1422
|
+
data: [
|
|
1423
|
+
{
|
|
1424
|
+
info: { role: 'assistant' },
|
|
1425
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
1426
|
+
},
|
|
1427
|
+
],
|
|
1428
|
+
},
|
|
1429
|
+
});
|
|
1430
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
1431
|
+
|
|
1432
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1433
|
+
|
|
1434
|
+
// First idle sets orchestrator
|
|
1435
|
+
await hook.handleEvent({
|
|
1436
|
+
event: {
|
|
1437
|
+
type: 'session.idle',
|
|
1438
|
+
properties: { sessionID: 'session-A' },
|
|
1439
|
+
},
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
await delay(60);
|
|
1443
|
+
|
|
1444
|
+
// Delete orchestrator session
|
|
1445
|
+
await hook.handleEvent({
|
|
1446
|
+
event: {
|
|
1447
|
+
type: 'session.deleted',
|
|
1448
|
+
properties: {
|
|
1449
|
+
sessionID: 'session-A',
|
|
1450
|
+
},
|
|
1451
|
+
},
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
// Second idle from new session should become orchestrator
|
|
1455
|
+
ctx.client.session.prompt.mockClear();
|
|
1456
|
+
await hook.handleEvent({
|
|
1457
|
+
event: {
|
|
1458
|
+
type: 'session.idle',
|
|
1459
|
+
properties: { sessionID: 'session-B' },
|
|
1460
|
+
},
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
await delay(60);
|
|
1464
|
+
|
|
1465
|
+
// Prompt should be called for session-B (new orchestrator)
|
|
1466
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
1467
|
+
const promptCall = contCall(ctx.client.session.prompt);
|
|
1468
|
+
expect(promptCall[0].path.id).toBe('session-B');
|
|
1469
|
+
});
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
describe('error handling', () => {
|
|
1473
|
+
test('fetch todos failure → skips continuation', async () => {
|
|
1474
|
+
const ctx = createMockContext({
|
|
1475
|
+
todoResult: undefined as any,
|
|
1476
|
+
});
|
|
1477
|
+
ctx.client.session.todo = mock(async () => {
|
|
1478
|
+
throw new Error('Failed to fetch todos');
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
1482
|
+
|
|
1483
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1484
|
+
|
|
1485
|
+
await hook.handleEvent({
|
|
1486
|
+
event: {
|
|
1487
|
+
type: 'session.idle',
|
|
1488
|
+
properties: { sessionID: 'session-123' },
|
|
1489
|
+
},
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
await delay(60);
|
|
1493
|
+
|
|
1494
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
test('fetch messages failure → skips continuation', async () => {
|
|
1498
|
+
const ctx = createMockContext({
|
|
1499
|
+
todoResult: {
|
|
1500
|
+
data: [
|
|
1501
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
1502
|
+
],
|
|
1503
|
+
},
|
|
1504
|
+
});
|
|
1505
|
+
ctx.client.session.messages = mock(async () => {
|
|
1506
|
+
throw new Error('Failed to fetch messages');
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
1510
|
+
|
|
1511
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1512
|
+
|
|
1513
|
+
await hook.handleEvent({
|
|
1514
|
+
event: {
|
|
1515
|
+
type: 'session.idle',
|
|
1516
|
+
properties: { sessionID: 'session-123' },
|
|
1517
|
+
},
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
await delay(60);
|
|
1521
|
+
|
|
1522
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
1523
|
+
});
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
describe('command.execute.before interception', () => {
|
|
1527
|
+
test('unrelated command → no interception', async () => {
|
|
1528
|
+
const ctx = createMockContext();
|
|
1529
|
+
const hook = createTodoContinuationHook(ctx);
|
|
1530
|
+
const output = { parts: [] as Array<{ type: string; text?: string }> };
|
|
1531
|
+
|
|
1532
|
+
await hook.handleCommandExecuteBefore(
|
|
1533
|
+
{ command: 'help', sessionID: 'session-123', arguments: '' },
|
|
1534
|
+
output,
|
|
1535
|
+
);
|
|
1536
|
+
|
|
1537
|
+
expect(output.parts).toHaveLength(0);
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
test('/auto-continue enables and injects continuation when incomplete todos', async () => {
|
|
1541
|
+
const ctx = createMockContext({
|
|
1542
|
+
todoResult: {
|
|
1543
|
+
data: [
|
|
1544
|
+
{
|
|
1545
|
+
id: '1',
|
|
1546
|
+
content: 'todo1',
|
|
1547
|
+
status: 'pending',
|
|
1548
|
+
priority: 'high',
|
|
1549
|
+
},
|
|
1550
|
+
],
|
|
1551
|
+
},
|
|
1552
|
+
});
|
|
1553
|
+
const hook = createTodoContinuationHook(ctx);
|
|
1554
|
+
const output = { parts: [] as Array<{ type: string; text?: string }> };
|
|
1555
|
+
|
|
1556
|
+
await hook.handleCommandExecuteBefore(
|
|
1557
|
+
{ command: 'auto-continue', sessionID: 'session-123', arguments: '' },
|
|
1558
|
+
output,
|
|
1559
|
+
);
|
|
1560
|
+
|
|
1561
|
+
expect(output.parts).toHaveLength(1);
|
|
1562
|
+
expect(output.parts[0].text).toContain(
|
|
1563
|
+
'[Auto-continue: enabled - there are incomplete todos remaining.',
|
|
1564
|
+
);
|
|
1565
|
+
expect(output.parts[0].text).toContain(SLIM_INTERNAL_INITIATOR_MARKER);
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
test('/auto-continue enables but no continuation when all todos complete', async () => {
|
|
1569
|
+
const ctx = createMockContext({
|
|
1570
|
+
todoResult: {
|
|
1571
|
+
data: [
|
|
1572
|
+
{
|
|
1573
|
+
id: '1',
|
|
1574
|
+
content: 'todo1',
|
|
1575
|
+
status: 'completed',
|
|
1576
|
+
priority: 'high',
|
|
1577
|
+
},
|
|
1578
|
+
],
|
|
1579
|
+
},
|
|
1580
|
+
});
|
|
1581
|
+
const hook = createTodoContinuationHook(ctx);
|
|
1582
|
+
const output = { parts: [] as Array<{ type: string; text?: string }> };
|
|
1583
|
+
|
|
1584
|
+
await hook.handleCommandExecuteBefore(
|
|
1585
|
+
{ command: 'auto-continue', sessionID: 'session-123', arguments: '' },
|
|
1586
|
+
output,
|
|
1587
|
+
);
|
|
1588
|
+
|
|
1589
|
+
expect(output.parts).toHaveLength(1);
|
|
1590
|
+
expect(output.parts[0].text).toContain('No incomplete todos right now');
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
test('/auto-continue toggles off when already enabled', async () => {
|
|
1594
|
+
const ctx = createMockContext();
|
|
1595
|
+
const hook = createTodoContinuationHook(ctx);
|
|
1596
|
+
const output = { parts: [] as Array<{ type: string; text?: string }> };
|
|
1597
|
+
|
|
1598
|
+
// Enable via tool
|
|
1599
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1600
|
+
|
|
1601
|
+
// Toggle off via command
|
|
1602
|
+
await hook.handleCommandExecuteBefore(
|
|
1603
|
+
{ command: 'auto-continue', sessionID: 'session-123', arguments: '' },
|
|
1604
|
+
output,
|
|
1605
|
+
);
|
|
1606
|
+
|
|
1607
|
+
expect(output.parts).toHaveLength(1);
|
|
1608
|
+
expect(output.parts[0].text).toContain('disabled by user command');
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
test('/auto-continue resets consecutive continuations on toggle', async () => {
|
|
1612
|
+
const ctx = createMockContext({
|
|
1613
|
+
todoResult: {
|
|
1614
|
+
data: [
|
|
1615
|
+
{
|
|
1616
|
+
id: '1',
|
|
1617
|
+
content: 'todo1',
|
|
1618
|
+
status: 'pending',
|
|
1619
|
+
priority: 'high',
|
|
1620
|
+
},
|
|
1621
|
+
],
|
|
1622
|
+
},
|
|
1623
|
+
messagesResult: {
|
|
1624
|
+
data: [
|
|
1625
|
+
{
|
|
1626
|
+
info: { role: 'assistant' },
|
|
1627
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
1628
|
+
},
|
|
1629
|
+
],
|
|
1630
|
+
},
|
|
1631
|
+
});
|
|
1632
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
1633
|
+
maxContinuations: 2,
|
|
1634
|
+
cooldownMs: 50,
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
// Enable and run up to max
|
|
1638
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1639
|
+
for (let i = 0; i < 2; i++) {
|
|
1640
|
+
await hook.handleEvent({
|
|
1641
|
+
event: {
|
|
1642
|
+
type: 'session.idle',
|
|
1643
|
+
properties: { sessionID: 'session-123' },
|
|
1644
|
+
},
|
|
1645
|
+
});
|
|
1646
|
+
await delay(60);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// Toggle off then on via command (resets count)
|
|
1650
|
+
const outputOff = {
|
|
1651
|
+
parts: [] as Array<{ type: string; text?: string }>,
|
|
1652
|
+
};
|
|
1653
|
+
await hook.handleCommandExecuteBefore(
|
|
1654
|
+
{ command: 'auto-continue', sessionID: 'session-123', arguments: '' },
|
|
1655
|
+
outputOff,
|
|
1656
|
+
);
|
|
1657
|
+
expect(outputOff.parts[0].text).toContain('disabled');
|
|
1658
|
+
|
|
1659
|
+
const outputOn = {
|
|
1660
|
+
parts: [] as Array<{ type: string; text?: string }>,
|
|
1661
|
+
};
|
|
1662
|
+
await hook.handleCommandExecuteBefore(
|
|
1663
|
+
{ command: 'auto-continue', sessionID: 'session-123', arguments: '' },
|
|
1664
|
+
outputOn,
|
|
1665
|
+
);
|
|
1666
|
+
// Should have continuation prompt again (count was reset)
|
|
1667
|
+
expect(outputOn.parts[0].text).toContain(
|
|
1668
|
+
'[Auto-continue: enabled - there are incomplete todos remaining.',
|
|
1669
|
+
);
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
test('/auto-continue with todo fetch failure → enables without continuation', async () => {
|
|
1673
|
+
const ctx = createMockContext();
|
|
1674
|
+
ctx.client.session.todo = mock(async () => {
|
|
1675
|
+
throw new Error('Network error');
|
|
1676
|
+
});
|
|
1677
|
+
const hook = createTodoContinuationHook(ctx);
|
|
1678
|
+
const output = { parts: [] as Array<{ type: string; text?: string }> };
|
|
1679
|
+
|
|
1680
|
+
await hook.handleCommandExecuteBefore(
|
|
1681
|
+
{ command: 'auto-continue', sessionID: 'session-123', arguments: '' },
|
|
1682
|
+
output,
|
|
1683
|
+
);
|
|
1684
|
+
|
|
1685
|
+
// Should still enable but skip continuation (no todos fetched)
|
|
1686
|
+
expect(output.parts).toHaveLength(1);
|
|
1687
|
+
expect(output.parts[0].text).toContain('No incomplete todos right now');
|
|
1688
|
+
});
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
describe('config defaults', () => {
|
|
1692
|
+
test('default config: maxContinuations = 5, cooldownMs = 3000', async () => {
|
|
1693
|
+
const ctx = createMockContext({
|
|
1694
|
+
todoResult: {
|
|
1695
|
+
data: [
|
|
1696
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
1697
|
+
],
|
|
1698
|
+
},
|
|
1699
|
+
messagesResult: {
|
|
1700
|
+
data: [
|
|
1701
|
+
{
|
|
1702
|
+
info: { role: 'assistant' },
|
|
1703
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
1704
|
+
},
|
|
1705
|
+
],
|
|
1706
|
+
},
|
|
1707
|
+
});
|
|
1708
|
+
const hook = createTodoContinuationHook(ctx); // No config passed
|
|
1709
|
+
|
|
1710
|
+
const result = await hook.tool.auto_continue.execute({ enabled: true });
|
|
1711
|
+
|
|
1712
|
+
expect(result).toContain('up to 5');
|
|
1713
|
+
|
|
1714
|
+
// Test default cooldown - we'll just verify it waits before calling
|
|
1715
|
+
await hook.handleEvent({
|
|
1716
|
+
event: {
|
|
1717
|
+
type: 'session.idle',
|
|
1718
|
+
properties: { sessionID: 'session-123' },
|
|
1719
|
+
},
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
// Wait less than default cooldown
|
|
1723
|
+
await delay(100);
|
|
1724
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
1725
|
+
|
|
1726
|
+
// Wait past default cooldown
|
|
1727
|
+
await delay(2900);
|
|
1728
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
1729
|
+
});
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
describe('review findings', () => {
|
|
1733
|
+
describe('CRITICAL-1: counter bypass via session.status→busy', () => {
|
|
1734
|
+
test('counter persists when busy fires during auto-injection', async () => {
|
|
1735
|
+
let promptResolve!: () => void;
|
|
1736
|
+
const ctx = createMockContext({
|
|
1737
|
+
todoResult: {
|
|
1738
|
+
data: [
|
|
1739
|
+
{
|
|
1740
|
+
id: '1',
|
|
1741
|
+
content: 't1',
|
|
1742
|
+
status: 'pending',
|
|
1743
|
+
priority: 'high',
|
|
1744
|
+
},
|
|
1745
|
+
],
|
|
1746
|
+
},
|
|
1747
|
+
messagesResult: {
|
|
1748
|
+
data: [
|
|
1749
|
+
{
|
|
1750
|
+
info: { role: 'assistant' },
|
|
1751
|
+
parts: [{ type: 'text', text: 'Work' }],
|
|
1752
|
+
},
|
|
1753
|
+
],
|
|
1754
|
+
},
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
// Make prompt hang so isAutoInjecting stays true
|
|
1758
|
+
ctx.client.session.prompt = mock(async () => {
|
|
1759
|
+
await new Promise<void>((r) => {
|
|
1760
|
+
promptResolve = r;
|
|
1761
|
+
});
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
1765
|
+
maxContinuations: 2,
|
|
1766
|
+
cooldownMs: 50,
|
|
1767
|
+
});
|
|
1768
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1769
|
+
|
|
1770
|
+
// Cycle 1: idle → timer → prompt hangs
|
|
1771
|
+
await hook.handleEvent({
|
|
1772
|
+
event: {
|
|
1773
|
+
type: 'session.idle',
|
|
1774
|
+
properties: { sessionID: 's1' },
|
|
1775
|
+
},
|
|
1776
|
+
});
|
|
1777
|
+
await delay(60);
|
|
1778
|
+
|
|
1779
|
+
// Session goes busy from prompt - isAutoInjecting is true,
|
|
1780
|
+
// so counter should NOT be reset
|
|
1781
|
+
await hook.handleEvent({
|
|
1782
|
+
event: {
|
|
1783
|
+
type: 'session.status',
|
|
1784
|
+
properties: {
|
|
1785
|
+
sessionID: 's1',
|
|
1786
|
+
status: { type: 'busy' },
|
|
1787
|
+
},
|
|
1788
|
+
},
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
// Resolve prompt → counter = 1
|
|
1792
|
+
promptResolve();
|
|
1793
|
+
await delay(10);
|
|
1794
|
+
|
|
1795
|
+
// Cycle 2: idle → timer → prompt hangs
|
|
1796
|
+
await hook.handleEvent({
|
|
1797
|
+
event: {
|
|
1798
|
+
type: 'session.idle',
|
|
1799
|
+
properties: { sessionID: 's1' },
|
|
1800
|
+
},
|
|
1801
|
+
});
|
|
1802
|
+
await delay(60);
|
|
1803
|
+
|
|
1804
|
+
// Session goes busy again - counter still not reset
|
|
1805
|
+
await hook.handleEvent({
|
|
1806
|
+
event: {
|
|
1807
|
+
type: 'session.status',
|
|
1808
|
+
properties: {
|
|
1809
|
+
sessionID: 's1',
|
|
1810
|
+
status: { type: 'busy' },
|
|
1811
|
+
},
|
|
1812
|
+
},
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
// Resolve prompt → counter = 2
|
|
1816
|
+
promptResolve();
|
|
1817
|
+
await delay(10);
|
|
1818
|
+
|
|
1819
|
+
// Cycle 3: counter = 2 >= maxContinuations = 2 → BLOCKED
|
|
1820
|
+
ctx.client.session.prompt = mock(async () => ({}));
|
|
1821
|
+
await hook.handleEvent({
|
|
1822
|
+
event: {
|
|
1823
|
+
type: 'session.idle',
|
|
1824
|
+
properties: { sessionID: 's1' },
|
|
1825
|
+
},
|
|
1826
|
+
});
|
|
1827
|
+
await delay(60);
|
|
1828
|
+
|
|
1829
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
1830
|
+
});
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
describe('CRITICAL-2: disable cancels pending timer', () => {
|
|
1834
|
+
test('tool disable during cooldown prevents injection', async () => {
|
|
1835
|
+
const ctx = createMockContext({
|
|
1836
|
+
todoResult: {
|
|
1837
|
+
data: [
|
|
1838
|
+
{
|
|
1839
|
+
id: '1',
|
|
1840
|
+
content: 't1',
|
|
1841
|
+
status: 'pending',
|
|
1842
|
+
priority: 'high',
|
|
1843
|
+
},
|
|
1844
|
+
],
|
|
1845
|
+
},
|
|
1846
|
+
messagesResult: {
|
|
1847
|
+
data: [
|
|
1848
|
+
{
|
|
1849
|
+
info: { role: 'assistant' },
|
|
1850
|
+
parts: [{ type: 'text', text: 'Work' }],
|
|
1851
|
+
},
|
|
1852
|
+
],
|
|
1853
|
+
},
|
|
1854
|
+
});
|
|
1855
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 100 });
|
|
1856
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1857
|
+
|
|
1858
|
+
// Fire idle → timer scheduled (100ms cooldown)
|
|
1859
|
+
await hook.handleEvent({
|
|
1860
|
+
event: {
|
|
1861
|
+
type: 'session.idle',
|
|
1862
|
+
properties: { sessionID: 's1' },
|
|
1863
|
+
},
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
// Disable before timer fires
|
|
1867
|
+
await delay(50);
|
|
1868
|
+
await hook.tool.auto_continue.execute({ enabled: false });
|
|
1869
|
+
|
|
1870
|
+
// Wait past original cooldown
|
|
1871
|
+
await delay(60);
|
|
1872
|
+
|
|
1873
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
test('command disable during cooldown prevents injection', async () => {
|
|
1877
|
+
const ctx = createMockContext({
|
|
1878
|
+
todoResult: {
|
|
1879
|
+
data: [
|
|
1880
|
+
{
|
|
1881
|
+
id: '1',
|
|
1882
|
+
content: 't1',
|
|
1883
|
+
status: 'pending',
|
|
1884
|
+
priority: 'high',
|
|
1885
|
+
},
|
|
1886
|
+
],
|
|
1887
|
+
},
|
|
1888
|
+
messagesResult: {
|
|
1889
|
+
data: [
|
|
1890
|
+
{
|
|
1891
|
+
info: { role: 'assistant' },
|
|
1892
|
+
parts: [{ type: 'text', text: 'Work' }],
|
|
1893
|
+
},
|
|
1894
|
+
],
|
|
1895
|
+
},
|
|
1896
|
+
});
|
|
1897
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 100 });
|
|
1898
|
+
|
|
1899
|
+
// Enable via command
|
|
1900
|
+
const outputOn = {
|
|
1901
|
+
parts: [] as Array<{ type: string; text?: string }>,
|
|
1902
|
+
};
|
|
1903
|
+
await hook.handleCommandExecuteBefore(
|
|
1904
|
+
{
|
|
1905
|
+
command: 'auto-continue',
|
|
1906
|
+
sessionID: 's1',
|
|
1907
|
+
arguments: 'on',
|
|
1908
|
+
},
|
|
1909
|
+
outputOn,
|
|
1910
|
+
);
|
|
1911
|
+
|
|
1912
|
+
// Fire idle → timer scheduled
|
|
1913
|
+
await hook.handleEvent({
|
|
1914
|
+
event: {
|
|
1915
|
+
type: 'session.idle',
|
|
1916
|
+
properties: { sessionID: 's1' },
|
|
1917
|
+
},
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
// Disable via command before timer fires
|
|
1921
|
+
await delay(50);
|
|
1922
|
+
const outputOff = {
|
|
1923
|
+
parts: [] as Array<{ type: string; text?: string }>,
|
|
1924
|
+
};
|
|
1925
|
+
await hook.handleCommandExecuteBefore(
|
|
1926
|
+
{
|
|
1927
|
+
command: 'auto-continue',
|
|
1928
|
+
sessionID: 's1',
|
|
1929
|
+
arguments: 'off',
|
|
1930
|
+
},
|
|
1931
|
+
outputOff,
|
|
1932
|
+
);
|
|
1933
|
+
|
|
1934
|
+
// Wait past original cooldown
|
|
1935
|
+
await delay(60);
|
|
1936
|
+
|
|
1937
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
1938
|
+
});
|
|
1939
|
+
});
|
|
1940
|
+
|
|
1941
|
+
describe('MAJOR-1: session.deleted resets counter', () => {
|
|
1942
|
+
test('deleted orchestrator session resets counter for next session', async () => {
|
|
1943
|
+
const ctx = createMockContext({
|
|
1944
|
+
todoResult: {
|
|
1945
|
+
data: [
|
|
1946
|
+
{
|
|
1947
|
+
id: '1',
|
|
1948
|
+
content: 't1',
|
|
1949
|
+
status: 'pending',
|
|
1950
|
+
priority: 'high',
|
|
1951
|
+
},
|
|
1952
|
+
],
|
|
1953
|
+
},
|
|
1954
|
+
messagesResult: {
|
|
1955
|
+
data: [
|
|
1956
|
+
{
|
|
1957
|
+
info: { role: 'assistant' },
|
|
1958
|
+
parts: [{ type: 'text', text: 'Work' }],
|
|
1959
|
+
},
|
|
1960
|
+
],
|
|
1961
|
+
},
|
|
1962
|
+
});
|
|
1963
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
1964
|
+
maxContinuations: 2,
|
|
1965
|
+
cooldownMs: 50,
|
|
1966
|
+
});
|
|
1967
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
1968
|
+
|
|
1969
|
+
// Cycle 1: idle → inject → counter = 1
|
|
1970
|
+
await hook.handleEvent({
|
|
1971
|
+
event: {
|
|
1972
|
+
type: 'session.idle',
|
|
1973
|
+
properties: { sessionID: 's1' },
|
|
1974
|
+
},
|
|
1975
|
+
});
|
|
1976
|
+
await delay(60);
|
|
1977
|
+
|
|
1978
|
+
// Delete orchestrator session → counter should reset
|
|
1979
|
+
await hook.handleEvent({
|
|
1980
|
+
event: {
|
|
1981
|
+
type: 'session.deleted',
|
|
1982
|
+
properties: { sessionID: 's1' },
|
|
1983
|
+
},
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
// New session becomes orchestrator - counter starts from 0
|
|
1987
|
+
ctx.client.session.prompt.mockClear();
|
|
1988
|
+
await hook.handleEvent({
|
|
1989
|
+
event: {
|
|
1990
|
+
type: 'session.idle',
|
|
1991
|
+
properties: { sessionID: 's2' },
|
|
1992
|
+
},
|
|
1993
|
+
});
|
|
1994
|
+
await delay(60); // counter = 1
|
|
1995
|
+
|
|
1996
|
+
// One more cycle → counter = 2 (reaches max)
|
|
1997
|
+
ctx.client.session.prompt.mockClear();
|
|
1998
|
+
await hook.handleEvent({
|
|
1999
|
+
event: {
|
|
2000
|
+
type: 'session.idle',
|
|
2001
|
+
properties: { sessionID: 's2' },
|
|
2002
|
+
},
|
|
2003
|
+
});
|
|
2004
|
+
await delay(60);
|
|
2005
|
+
|
|
2006
|
+
// Third cycle blocked (counter = 2 >= max = 2)
|
|
2007
|
+
ctx.client.session.prompt.mockClear();
|
|
2008
|
+
await hook.handleEvent({
|
|
2009
|
+
event: {
|
|
2010
|
+
type: 'session.idle',
|
|
2011
|
+
properties: { sessionID: 's2' },
|
|
2012
|
+
},
|
|
2013
|
+
});
|
|
2014
|
+
await delay(60);
|
|
2015
|
+
|
|
2016
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
2017
|
+
});
|
|
2018
|
+
});
|
|
2019
|
+
|
|
2020
|
+
describe('MAJOR-2: suppressUntil cleared on re-enable', () => {
|
|
2021
|
+
test('tool re-enable clears suppress window', async () => {
|
|
2022
|
+
const ctx = createMockContext({
|
|
2023
|
+
todoResult: {
|
|
2024
|
+
data: [
|
|
2025
|
+
{
|
|
2026
|
+
id: '1',
|
|
2027
|
+
content: 't1',
|
|
2028
|
+
status: 'pending',
|
|
2029
|
+
priority: 'high',
|
|
2030
|
+
},
|
|
2031
|
+
],
|
|
2032
|
+
},
|
|
2033
|
+
messagesResult: {
|
|
2034
|
+
data: [
|
|
2035
|
+
{
|
|
2036
|
+
info: { role: 'assistant' },
|
|
2037
|
+
parts: [{ type: 'text', text: 'Work' }],
|
|
2038
|
+
},
|
|
2039
|
+
],
|
|
2040
|
+
},
|
|
2041
|
+
});
|
|
2042
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
2043
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2044
|
+
|
|
2045
|
+
// Fire abort → sets suppress window
|
|
2046
|
+
await hook.handleEvent({
|
|
2047
|
+
event: {
|
|
2048
|
+
type: 'session.error',
|
|
2049
|
+
properties: {
|
|
2050
|
+
sessionID: 's1',
|
|
2051
|
+
error: { name: 'AbortError' },
|
|
2052
|
+
},
|
|
2053
|
+
},
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
// Re-enable within suppress window → clears suppressUntil
|
|
2057
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2058
|
+
|
|
2059
|
+
// Fire idle → should NOT be suppressed
|
|
2060
|
+
await hook.handleEvent({
|
|
2061
|
+
event: {
|
|
2062
|
+
type: 'session.idle',
|
|
2063
|
+
properties: { sessionID: 's1' },
|
|
2064
|
+
},
|
|
2065
|
+
});
|
|
2066
|
+
await delay(60);
|
|
2067
|
+
|
|
2068
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
test('command re-enable clears suppress window', async () => {
|
|
2072
|
+
const ctx = createMockContext({
|
|
2073
|
+
todoResult: {
|
|
2074
|
+
data: [
|
|
2075
|
+
{
|
|
2076
|
+
id: '1',
|
|
2077
|
+
content: 't1',
|
|
2078
|
+
status: 'pending',
|
|
2079
|
+
priority: 'high',
|
|
2080
|
+
},
|
|
2081
|
+
],
|
|
2082
|
+
},
|
|
2083
|
+
messagesResult: {
|
|
2084
|
+
data: [
|
|
2085
|
+
{
|
|
2086
|
+
info: { role: 'assistant' },
|
|
2087
|
+
parts: [{ type: 'text', text: 'Work' }],
|
|
2088
|
+
},
|
|
2089
|
+
],
|
|
2090
|
+
},
|
|
2091
|
+
});
|
|
2092
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
2093
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2094
|
+
|
|
2095
|
+
// Fire abort → sets suppress window
|
|
2096
|
+
await hook.handleEvent({
|
|
2097
|
+
event: {
|
|
2098
|
+
type: 'session.error',
|
|
2099
|
+
properties: {
|
|
2100
|
+
sessionID: 's1',
|
|
2101
|
+
error: { name: 'AbortError' },
|
|
2102
|
+
},
|
|
2103
|
+
},
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
// Re-enable via command → clears suppressUntil
|
|
2107
|
+
const output = {
|
|
2108
|
+
parts: [] as Array<{ type: string; text?: string }>,
|
|
2109
|
+
};
|
|
2110
|
+
await hook.handleCommandExecuteBefore(
|
|
2111
|
+
{
|
|
2112
|
+
command: 'auto-continue',
|
|
2113
|
+
sessionID: 's1',
|
|
2114
|
+
arguments: 'on',
|
|
2115
|
+
},
|
|
2116
|
+
output,
|
|
2117
|
+
);
|
|
2118
|
+
|
|
2119
|
+
// Fire idle → should NOT be suppressed
|
|
2120
|
+
await hook.handleEvent({
|
|
2121
|
+
event: {
|
|
2122
|
+
type: 'session.idle',
|
|
2123
|
+
properties: { sessionID: 's1' },
|
|
2124
|
+
},
|
|
2125
|
+
});
|
|
2126
|
+
await delay(60);
|
|
2127
|
+
|
|
2128
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2129
|
+
});
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
describe('error paths', () => {
|
|
2133
|
+
test('prompt failure in timer callback is handled gracefully', async () => {
|
|
2134
|
+
const ctx = createMockContext({
|
|
2135
|
+
todoResult: {
|
|
2136
|
+
data: [
|
|
2137
|
+
{
|
|
2138
|
+
id: '1',
|
|
2139
|
+
content: 't1',
|
|
2140
|
+
status: 'pending',
|
|
2141
|
+
priority: 'high',
|
|
2142
|
+
},
|
|
2143
|
+
],
|
|
2144
|
+
},
|
|
2145
|
+
messagesResult: {
|
|
2146
|
+
data: [
|
|
2147
|
+
{
|
|
2148
|
+
info: { role: 'assistant' },
|
|
2149
|
+
parts: [{ type: 'text', text: 'Work' }],
|
|
2150
|
+
},
|
|
2151
|
+
],
|
|
2152
|
+
},
|
|
2153
|
+
});
|
|
2154
|
+
ctx.client.session.prompt = mock(async () => {
|
|
2155
|
+
throw new Error('API error');
|
|
2156
|
+
});
|
|
2157
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
2158
|
+
|
|
2159
|
+
// Seed orchestrator session
|
|
2160
|
+
await hook.handleEvent({
|
|
2161
|
+
event: {
|
|
2162
|
+
type: 'session.idle',
|
|
2163
|
+
properties: { sessionID: 's1' },
|
|
2164
|
+
},
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2168
|
+
|
|
2169
|
+
await hook.handleEvent({
|
|
2170
|
+
event: {
|
|
2171
|
+
type: 'session.idle',
|
|
2172
|
+
properties: { sessionID: 's1' },
|
|
2173
|
+
},
|
|
2174
|
+
});
|
|
2175
|
+
await delay(60);
|
|
2176
|
+
|
|
2177
|
+
// Error caught; isAutoInjecting should be cleared via finally.
|
|
2178
|
+
// Verify by checking a second idle still works.
|
|
2179
|
+
ctx.client.session.prompt = mock(async () => ({}));
|
|
2180
|
+
await hook.handleEvent({
|
|
2181
|
+
event: {
|
|
2182
|
+
type: 'session.idle',
|
|
2183
|
+
properties: { sessionID: 's1' },
|
|
2184
|
+
},
|
|
2185
|
+
});
|
|
2186
|
+
await delay(60);
|
|
2187
|
+
|
|
2188
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2189
|
+
});
|
|
2190
|
+
});
|
|
2191
|
+
|
|
2192
|
+
describe('edge cases', () => {
|
|
2193
|
+
test('session.idle with missing sessionID returns early', async () => {
|
|
2194
|
+
const ctx = createMockContext();
|
|
2195
|
+
const hook = createTodoContinuationHook(ctx);
|
|
2196
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2197
|
+
|
|
2198
|
+
// Fire idle without sessionID - should not throw
|
|
2199
|
+
await hook.handleEvent({
|
|
2200
|
+
event: { type: 'session.idle', properties: {} },
|
|
2201
|
+
});
|
|
2202
|
+
|
|
2203
|
+
expect(ctx.client.session.todo).not.toHaveBeenCalled();
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
test('session.deleted with properties.info.id path', async () => {
|
|
2207
|
+
const ctx = createMockContext({
|
|
2208
|
+
todoResult: {
|
|
2209
|
+
data: [
|
|
2210
|
+
{
|
|
2211
|
+
id: '1',
|
|
2212
|
+
content: 't1',
|
|
2213
|
+
status: 'pending',
|
|
2214
|
+
priority: 'high',
|
|
2215
|
+
},
|
|
2216
|
+
],
|
|
2217
|
+
},
|
|
2218
|
+
messagesResult: {
|
|
2219
|
+
data: [
|
|
2220
|
+
{
|
|
2221
|
+
info: { role: 'assistant' },
|
|
2222
|
+
parts: [{ type: 'text', text: 'Work' }],
|
|
2223
|
+
},
|
|
2224
|
+
],
|
|
2225
|
+
},
|
|
2226
|
+
});
|
|
2227
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
2228
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2229
|
+
|
|
2230
|
+
// Set orchestrator via idle
|
|
2231
|
+
await hook.handleEvent({
|
|
2232
|
+
event: {
|
|
2233
|
+
type: 'session.idle',
|
|
2234
|
+
properties: { sessionID: 's1' },
|
|
2235
|
+
},
|
|
2236
|
+
});
|
|
2237
|
+
await delay(60);
|
|
2238
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2239
|
+
|
|
2240
|
+
// Delete via info.id path (alternative shape from session store)
|
|
2241
|
+
await hook.handleEvent({
|
|
2242
|
+
event: {
|
|
2243
|
+
type: 'session.deleted',
|
|
2244
|
+
properties: { info: { id: 's1' } },
|
|
2245
|
+
},
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
// New session should become orchestrator
|
|
2249
|
+
ctx.client.session.prompt.mockClear();
|
|
2250
|
+
await hook.handleEvent({
|
|
2251
|
+
event: {
|
|
2252
|
+
type: 'session.idle',
|
|
2253
|
+
properties: { sessionID: 's2' },
|
|
2254
|
+
},
|
|
2255
|
+
});
|
|
2256
|
+
await delay(60);
|
|
2257
|
+
|
|
2258
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2259
|
+
expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('s2');
|
|
2260
|
+
});
|
|
2261
|
+
|
|
2262
|
+
test('cooldownMs = 0 fires on next tick', async () => {
|
|
2263
|
+
const ctx = createMockContext({
|
|
2264
|
+
todoResult: {
|
|
2265
|
+
data: [
|
|
2266
|
+
{
|
|
2267
|
+
id: '1',
|
|
2268
|
+
content: 't1',
|
|
2269
|
+
status: 'pending',
|
|
2270
|
+
priority: 'high',
|
|
2271
|
+
},
|
|
2272
|
+
],
|
|
2273
|
+
},
|
|
2274
|
+
messagesResult: {
|
|
2275
|
+
data: [
|
|
2276
|
+
{
|
|
2277
|
+
info: { role: 'assistant' },
|
|
2278
|
+
parts: [{ type: 'text', text: 'Work' }],
|
|
2279
|
+
},
|
|
2280
|
+
],
|
|
2281
|
+
},
|
|
2282
|
+
});
|
|
2283
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2284
|
+
cooldownMs: 0,
|
|
2285
|
+
maxContinuations: 5,
|
|
2286
|
+
});
|
|
2287
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2288
|
+
|
|
2289
|
+
await hook.handleEvent({
|
|
2290
|
+
event: {
|
|
2291
|
+
type: 'session.idle',
|
|
2292
|
+
properties: { sessionID: 's1' },
|
|
2293
|
+
},
|
|
2294
|
+
});
|
|
2295
|
+
await delay(10);
|
|
2296
|
+
|
|
2297
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2298
|
+
});
|
|
2299
|
+
});
|
|
2300
|
+
|
|
2301
|
+
describe('MAJOR-3: double-fire prevention', () => {
|
|
2302
|
+
test('rapid idle events during prompt delivery - single continuation', async () => {
|
|
2303
|
+
let promptResolve!: () => void;
|
|
2304
|
+
const ctx = createMockContext({
|
|
2305
|
+
todoResult: {
|
|
2306
|
+
data: [
|
|
2307
|
+
{
|
|
2308
|
+
id: '1',
|
|
2309
|
+
content: 't1',
|
|
2310
|
+
status: 'pending',
|
|
2311
|
+
priority: 'high',
|
|
2312
|
+
},
|
|
2313
|
+
],
|
|
2314
|
+
},
|
|
2315
|
+
messagesResult: {
|
|
2316
|
+
data: [
|
|
2317
|
+
{
|
|
2318
|
+
info: { role: 'assistant' },
|
|
2319
|
+
parts: [{ type: 'text', text: 'Work' }],
|
|
2320
|
+
},
|
|
2321
|
+
],
|
|
2322
|
+
},
|
|
2323
|
+
});
|
|
2324
|
+
ctx.client.session.prompt = mock(async () => {
|
|
2325
|
+
await new Promise<void>((r) => {
|
|
2326
|
+
promptResolve = r;
|
|
2327
|
+
});
|
|
2328
|
+
});
|
|
2329
|
+
|
|
2330
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
2331
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2332
|
+
|
|
2333
|
+
// Fire idle → timer → prompt hangs (isAutoInjecting = true)
|
|
2334
|
+
await hook.handleEvent({
|
|
2335
|
+
event: {
|
|
2336
|
+
type: 'session.idle',
|
|
2337
|
+
properties: { sessionID: 's1' },
|
|
2338
|
+
},
|
|
2339
|
+
});
|
|
2340
|
+
await delay(60);
|
|
2341
|
+
|
|
2342
|
+
// Fire another idle while prompt is in flight
|
|
2343
|
+
await hook.handleEvent({
|
|
2344
|
+
event: {
|
|
2345
|
+
type: 'session.idle',
|
|
2346
|
+
properties: { sessionID: 's1' },
|
|
2347
|
+
},
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
// Only one prompt call (blocked by isAutoInjecting gate)
|
|
2351
|
+
expect(contCount(ctx.client.session.prompt)).toBe(1);
|
|
2352
|
+
|
|
2353
|
+
// Resolve prompt
|
|
2354
|
+
promptResolve();
|
|
2355
|
+
await delay(10);
|
|
2356
|
+
|
|
2357
|
+
// Now idle should schedule a new timer
|
|
2358
|
+
ctx.client.session.prompt = mock(async () => ({}));
|
|
2359
|
+
await hook.handleEvent({
|
|
2360
|
+
event: {
|
|
2361
|
+
type: 'session.idle',
|
|
2362
|
+
properties: { sessionID: 's1' },
|
|
2363
|
+
},
|
|
2364
|
+
});
|
|
2365
|
+
await delay(60);
|
|
2366
|
+
|
|
2367
|
+
expect(contCount(ctx.client.session.prompt)).toBe(1);
|
|
2368
|
+
});
|
|
2369
|
+
});
|
|
2370
|
+
|
|
2371
|
+
describe('MAJOR-4: command explicit on|off arguments', () => {
|
|
2372
|
+
test('command "on" keeps enabled state when already enabled', async () => {
|
|
2373
|
+
const ctx = createMockContext();
|
|
2374
|
+
const hook = createTodoContinuationHook(ctx);
|
|
2375
|
+
|
|
2376
|
+
// Enable via tool
|
|
2377
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2378
|
+
|
|
2379
|
+
// /auto-continue on → should KEEP enabled (not toggle to off)
|
|
2380
|
+
const output = {
|
|
2381
|
+
parts: [] as Array<{ type: string; text?: string }>,
|
|
2382
|
+
};
|
|
2383
|
+
await hook.handleCommandExecuteBefore(
|
|
2384
|
+
{
|
|
2385
|
+
command: 'auto-continue',
|
|
2386
|
+
sessionID: 's1',
|
|
2387
|
+
arguments: 'on',
|
|
2388
|
+
},
|
|
2389
|
+
output,
|
|
2390
|
+
);
|
|
2391
|
+
|
|
2392
|
+
expect(output.parts[0].text).not.toContain('disabled');
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
test('command "off" keeps disabled state when already disabled', async () => {
|
|
2396
|
+
const ctx = createMockContext();
|
|
2397
|
+
const hook = createTodoContinuationHook(ctx);
|
|
2398
|
+
|
|
2399
|
+
// Start disabled (default)
|
|
2400
|
+
const output = {
|
|
2401
|
+
parts: [] as Array<{ type: string; text?: string }>,
|
|
2402
|
+
};
|
|
2403
|
+
await hook.handleCommandExecuteBefore(
|
|
2404
|
+
{
|
|
2405
|
+
command: 'auto-continue',
|
|
2406
|
+
sessionID: 's1',
|
|
2407
|
+
arguments: 'off',
|
|
2408
|
+
},
|
|
2409
|
+
output,
|
|
2410
|
+
);
|
|
2411
|
+
|
|
2412
|
+
expect(output.parts[0].text).toContain('disabled');
|
|
2413
|
+
});
|
|
2414
|
+
|
|
2415
|
+
test('command with no argument toggles state', async () => {
|
|
2416
|
+
const ctx = createMockContext();
|
|
2417
|
+
const hook = createTodoContinuationHook(ctx);
|
|
2418
|
+
|
|
2419
|
+
// First toggle: disabled → enabled
|
|
2420
|
+
const output1 = {
|
|
2421
|
+
parts: [] as Array<{ type: string; text?: string }>,
|
|
2422
|
+
};
|
|
2423
|
+
await hook.handleCommandExecuteBefore(
|
|
2424
|
+
{
|
|
2425
|
+
command: 'auto-continue',
|
|
2426
|
+
sessionID: 's1',
|
|
2427
|
+
arguments: '',
|
|
2428
|
+
},
|
|
2429
|
+
output1,
|
|
2430
|
+
);
|
|
2431
|
+
expect(output1.parts[0].text).not.toContain('disabled');
|
|
2432
|
+
|
|
2433
|
+
// Second toggle: enabled → disabled
|
|
2434
|
+
const output2 = {
|
|
2435
|
+
parts: [] as Array<{ type: string; text?: string }>,
|
|
2436
|
+
};
|
|
2437
|
+
await hook.handleCommandExecuteBefore(
|
|
2438
|
+
{
|
|
2439
|
+
command: 'auto-continue',
|
|
2440
|
+
sessionID: 's1',
|
|
2441
|
+
arguments: '',
|
|
2442
|
+
},
|
|
2443
|
+
output2,
|
|
2444
|
+
);
|
|
2445
|
+
expect(output2.parts[0].text).toContain('disabled');
|
|
2446
|
+
});
|
|
2447
|
+
});
|
|
2448
|
+
});
|
|
2449
|
+
|
|
2450
|
+
describe('session routing and notification cancellation', () => {
|
|
2451
|
+
function createPendingCtx() {
|
|
2452
|
+
return createMockContext({
|
|
2453
|
+
todoResult: {
|
|
2454
|
+
data: [
|
|
2455
|
+
{ id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
2456
|
+
],
|
|
2457
|
+
},
|
|
2458
|
+
messagesResult: {
|
|
2459
|
+
data: [
|
|
2460
|
+
{
|
|
2461
|
+
info: { role: 'assistant' },
|
|
2462
|
+
parts: [{ type: 'text', text: 'Work in progress' }],
|
|
2463
|
+
},
|
|
2464
|
+
],
|
|
2465
|
+
},
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
test('chat.message registers orchestrator sessions without first-idle lockout', async () => {
|
|
2470
|
+
const ctx = createPendingCtx();
|
|
2471
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2472
|
+
cooldownMs: 50,
|
|
2473
|
+
});
|
|
2474
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2475
|
+
|
|
2476
|
+
hook.handleChatMessage({ sessionID: 'sub1', agent: 'fixer' });
|
|
2477
|
+
hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
2478
|
+
hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
|
|
2479
|
+
|
|
2480
|
+
await hook.handleEvent({
|
|
2481
|
+
event: {
|
|
2482
|
+
type: 'session.idle',
|
|
2483
|
+
properties: { sessionID: 'sub1' },
|
|
2484
|
+
},
|
|
2485
|
+
});
|
|
2486
|
+
await delay(60);
|
|
2487
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
2488
|
+
|
|
2489
|
+
await hook.handleEvent({
|
|
2490
|
+
event: {
|
|
2491
|
+
type: 'session.idle',
|
|
2492
|
+
properties: { sessionID: 'main2' },
|
|
2493
|
+
},
|
|
2494
|
+
});
|
|
2495
|
+
await delay(60);
|
|
2496
|
+
|
|
2497
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2498
|
+
expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('main2');
|
|
2499
|
+
});
|
|
2500
|
+
|
|
2501
|
+
test('chat.message without agent does not block legacy first-idle fallback', async () => {
|
|
2502
|
+
const ctx = createPendingCtx();
|
|
2503
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2504
|
+
cooldownMs: 50,
|
|
2505
|
+
autoEnable: true,
|
|
2506
|
+
autoEnableThreshold: 1,
|
|
2507
|
+
});
|
|
2508
|
+
|
|
2509
|
+
hook.handleChatMessage({ sessionID: 'main1' });
|
|
2510
|
+
await hook.handleEvent({
|
|
2511
|
+
event: {
|
|
2512
|
+
type: 'session.idle',
|
|
2513
|
+
properties: { sessionID: 'main1' },
|
|
2514
|
+
},
|
|
2515
|
+
});
|
|
2516
|
+
await delay(60);
|
|
2517
|
+
|
|
2518
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2519
|
+
});
|
|
2520
|
+
|
|
2521
|
+
test('subagent chat.message prevents first-idle fallback registration', async () => {
|
|
2522
|
+
const ctx = createPendingCtx();
|
|
2523
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2524
|
+
cooldownMs: 50,
|
|
2525
|
+
autoEnable: true,
|
|
2526
|
+
autoEnableThreshold: 1,
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
hook.handleChatMessage({ sessionID: 'sub1', agent: 'fixer' });
|
|
2530
|
+
await hook.handleEvent({
|
|
2531
|
+
event: {
|
|
2532
|
+
type: 'session.idle',
|
|
2533
|
+
properties: { sessionID: 'sub1' },
|
|
2534
|
+
},
|
|
2535
|
+
});
|
|
2536
|
+
await delay(60);
|
|
2537
|
+
|
|
2538
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
2539
|
+
});
|
|
2540
|
+
|
|
2541
|
+
test('session.status idle triggers continuation like session.idle', async () => {
|
|
2542
|
+
const ctx = createPendingCtx();
|
|
2543
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
2544
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2545
|
+
hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
2546
|
+
|
|
2547
|
+
await hook.handleEvent({
|
|
2548
|
+
event: {
|
|
2549
|
+
type: 'session.status',
|
|
2550
|
+
properties: { sessionID: 'main1', status: { type: 'idle' } },
|
|
2551
|
+
},
|
|
2552
|
+
});
|
|
2553
|
+
await delay(60);
|
|
2554
|
+
|
|
2555
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
test('deleting another orchestrator does not cancel the active session timer', async () => {
|
|
2559
|
+
const ctx = createPendingCtx();
|
|
2560
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
2561
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2562
|
+
hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
2563
|
+
hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
|
|
2564
|
+
|
|
2565
|
+
await hook.handleEvent({
|
|
2566
|
+
event: {
|
|
2567
|
+
type: 'session.idle',
|
|
2568
|
+
properties: { sessionID: 'main1' },
|
|
2569
|
+
},
|
|
2570
|
+
});
|
|
2571
|
+
await hook.handleEvent({
|
|
2572
|
+
event: {
|
|
2573
|
+
type: 'session.deleted',
|
|
2574
|
+
properties: { sessionID: 'main2' },
|
|
2575
|
+
},
|
|
2576
|
+
});
|
|
2577
|
+
await delay(60);
|
|
2578
|
+
|
|
2579
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2580
|
+
expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('main1');
|
|
2581
|
+
});
|
|
2582
|
+
|
|
2583
|
+
test('deleting all orchestrators restores legacy first-idle fallback', async () => {
|
|
2584
|
+
const ctx = createPendingCtx();
|
|
2585
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2586
|
+
cooldownMs: 50,
|
|
2587
|
+
autoEnable: true,
|
|
2588
|
+
autoEnableThreshold: 1,
|
|
2589
|
+
});
|
|
2590
|
+
hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
2591
|
+
hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
|
|
2592
|
+
|
|
2593
|
+
await hook.handleEvent({
|
|
2594
|
+
event: {
|
|
2595
|
+
type: 'session.deleted',
|
|
2596
|
+
properties: { sessionID: 'main1' },
|
|
2597
|
+
},
|
|
2598
|
+
});
|
|
2599
|
+
await hook.handleEvent({
|
|
2600
|
+
event: {
|
|
2601
|
+
type: 'session.deleted',
|
|
2602
|
+
properties: { sessionID: 'main2' },
|
|
2603
|
+
},
|
|
2604
|
+
});
|
|
2605
|
+
|
|
2606
|
+
await hook.handleEvent({
|
|
2607
|
+
event: {
|
|
2608
|
+
type: 'session.idle',
|
|
2609
|
+
properties: { sessionID: 'legacy-main' },
|
|
2610
|
+
},
|
|
2611
|
+
});
|
|
2612
|
+
await delay(60);
|
|
2613
|
+
|
|
2614
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2615
|
+
expect(contCall(ctx.client.session.prompt)[0].path.id).toBe(
|
|
2616
|
+
'legacy-main',
|
|
2617
|
+
);
|
|
2618
|
+
});
|
|
2619
|
+
|
|
2620
|
+
test('countdown notification busy status does not reset max-continuation counter', async () => {
|
|
2621
|
+
const ctx = createPendingCtx();
|
|
2622
|
+
const releaseNotifications: Array<() => void> = [];
|
|
2623
|
+
ctx.client.session.prompt = mock(async (args: any) => {
|
|
2624
|
+
if (args?.body?.noReply === true) {
|
|
2625
|
+
await new Promise<void>((resolve) => {
|
|
2626
|
+
releaseNotifications.push(resolve);
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
return {};
|
|
2630
|
+
});
|
|
2631
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2632
|
+
cooldownMs: 50,
|
|
2633
|
+
maxContinuations: 2,
|
|
2634
|
+
});
|
|
2635
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2636
|
+
hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
2637
|
+
|
|
2638
|
+
for (let i = 0; i < 2; i++) {
|
|
2639
|
+
await hook.handleEvent({
|
|
2640
|
+
event: {
|
|
2641
|
+
type: 'session.idle',
|
|
2642
|
+
properties: { sessionID: 'main1' },
|
|
2643
|
+
},
|
|
2644
|
+
});
|
|
2645
|
+
await hook.handleEvent({
|
|
2646
|
+
event: {
|
|
2647
|
+
type: 'session.status',
|
|
2648
|
+
properties: { sessionID: 'main1', status: { type: 'busy' } },
|
|
2649
|
+
},
|
|
2650
|
+
});
|
|
2651
|
+
await delay(60);
|
|
2652
|
+
releaseNotifications.shift()?.();
|
|
2653
|
+
await delay(10);
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
ctx.client.session.prompt.mockClear();
|
|
2657
|
+
await hook.handleEvent({
|
|
2658
|
+
event: {
|
|
2659
|
+
type: 'session.idle',
|
|
2660
|
+
properties: { sessionID: 'main1' },
|
|
2661
|
+
},
|
|
2662
|
+
});
|
|
2663
|
+
await delay(60);
|
|
2664
|
+
|
|
2665
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
2666
|
+
});
|
|
2667
|
+
|
|
2668
|
+
test('late countdown notification busy status does not cancel continuation timer', async () => {
|
|
2669
|
+
const ctx = createPendingCtx();
|
|
2670
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
2671
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2672
|
+
hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
2673
|
+
|
|
2674
|
+
await hook.handleEvent({
|
|
2675
|
+
event: {
|
|
2676
|
+
type: 'session.idle',
|
|
2677
|
+
properties: { sessionID: 'main1' },
|
|
2678
|
+
},
|
|
2679
|
+
});
|
|
2680
|
+
await delay(10);
|
|
2681
|
+
await hook.handleEvent({
|
|
2682
|
+
event: {
|
|
2683
|
+
type: 'session.status',
|
|
2684
|
+
properties: { sessionID: 'main1', status: { type: 'busy' } },
|
|
2685
|
+
},
|
|
2686
|
+
});
|
|
2687
|
+
await delay(60);
|
|
2688
|
+
|
|
2689
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2690
|
+
});
|
|
2691
|
+
|
|
2692
|
+
test('countdown notification busy status does not cancel continuation timer', async () => {
|
|
2693
|
+
const ctx = createPendingCtx();
|
|
2694
|
+
let callCount = 0;
|
|
2695
|
+
ctx.client.session.prompt = mock(async () => {
|
|
2696
|
+
callCount++;
|
|
2697
|
+
return {};
|
|
2698
|
+
});
|
|
2699
|
+
const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
2700
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2701
|
+
hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
2702
|
+
|
|
2703
|
+
await hook.handleEvent({
|
|
2704
|
+
event: {
|
|
2705
|
+
type: 'session.idle',
|
|
2706
|
+
properties: { sessionID: 'main1' },
|
|
2707
|
+
},
|
|
2708
|
+
});
|
|
2709
|
+
await hook.handleEvent({
|
|
2710
|
+
event: {
|
|
2711
|
+
type: 'session.status',
|
|
2712
|
+
properties: { sessionID: 'main1', status: { type: 'busy' } },
|
|
2713
|
+
},
|
|
2714
|
+
});
|
|
2715
|
+
await delay(60);
|
|
2716
|
+
|
|
2717
|
+
expect(callCount).toBeGreaterThanOrEqual(2);
|
|
2718
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2719
|
+
});
|
|
2720
|
+
});
|
|
2721
|
+
|
|
2722
|
+
describe('auto-enable on todo count', () => {
|
|
2723
|
+
function createAutoEnableCtx(
|
|
2724
|
+
todos: Array<{
|
|
2725
|
+
id: string;
|
|
2726
|
+
content: string;
|
|
2727
|
+
status: string;
|
|
2728
|
+
priority: string;
|
|
2729
|
+
}>,
|
|
2730
|
+
) {
|
|
2731
|
+
return createMockContext({
|
|
2732
|
+
todoResult: { data: todos },
|
|
2733
|
+
messagesResult: {
|
|
2734
|
+
data: [
|
|
2735
|
+
{
|
|
2736
|
+
info: { role: 'assistant' },
|
|
2737
|
+
parts: [{ type: 'text', text: 'Working...' }],
|
|
2738
|
+
},
|
|
2739
|
+
],
|
|
2740
|
+
},
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
test('autoEnable=true, todos >= threshold → auto-enables and continues', async () => {
|
|
2745
|
+
const ctx = createAutoEnableCtx([
|
|
2746
|
+
{ id: '1', content: 't1', status: 'pending', priority: 'high' },
|
|
2747
|
+
{ id: '2', content: 't2', status: 'pending', priority: 'high' },
|
|
2748
|
+
{ id: '3', content: 't3', status: 'pending', priority: 'high' },
|
|
2749
|
+
{ id: '4', content: 't4', status: 'pending', priority: 'high' },
|
|
2750
|
+
]);
|
|
2751
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2752
|
+
maxContinuations: 5,
|
|
2753
|
+
cooldownMs: 50,
|
|
2754
|
+
autoEnable: true,
|
|
2755
|
+
autoEnableThreshold: 4,
|
|
2756
|
+
});
|
|
2757
|
+
|
|
2758
|
+
// Do NOT manually enable - auto-enable should trigger
|
|
2759
|
+
await hook.handleEvent({
|
|
2760
|
+
event: {
|
|
2761
|
+
type: 'session.idle',
|
|
2762
|
+
properties: { sessionID: 's1' },
|
|
2763
|
+
},
|
|
2764
|
+
});
|
|
2765
|
+
|
|
2766
|
+
await delay(60);
|
|
2767
|
+
|
|
2768
|
+
// Should have scheduled continuation (auto-enabled)
|
|
2769
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2770
|
+
});
|
|
2771
|
+
|
|
2772
|
+
test('autoEnable=true, todos < threshold → does NOT auto-enable', async () => {
|
|
2773
|
+
const ctx = createAutoEnableCtx([
|
|
2774
|
+
{ id: '1', content: 't1', status: 'pending', priority: 'high' },
|
|
2775
|
+
{ id: '2', content: 't2', status: 'pending', priority: 'high' },
|
|
2776
|
+
{ id: '3', content: 't3', status: 'pending', priority: 'high' },
|
|
2777
|
+
]);
|
|
2778
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2779
|
+
maxContinuations: 5,
|
|
2780
|
+
cooldownMs: 50,
|
|
2781
|
+
autoEnable: true,
|
|
2782
|
+
autoEnableThreshold: 4,
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
await hook.handleEvent({
|
|
2786
|
+
event: {
|
|
2787
|
+
type: 'session.idle',
|
|
2788
|
+
properties: { sessionID: 's1' },
|
|
2789
|
+
},
|
|
2790
|
+
});
|
|
2791
|
+
|
|
2792
|
+
await delay(60);
|
|
2793
|
+
|
|
2794
|
+
// Should NOT auto-enable or continue
|
|
2795
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
2796
|
+
});
|
|
2797
|
+
|
|
2798
|
+
test('autoEnable=false (default) → never auto-enables regardless of todo count', async () => {
|
|
2799
|
+
const ctx = createAutoEnableCtx(
|
|
2800
|
+
Array.from({ length: 10 }, (_, i) => ({
|
|
2801
|
+
id: String(i),
|
|
2802
|
+
content: `t${i}`,
|
|
2803
|
+
status: 'pending',
|
|
2804
|
+
priority: 'high',
|
|
2805
|
+
})),
|
|
2806
|
+
);
|
|
2807
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2808
|
+
maxContinuations: 5,
|
|
2809
|
+
cooldownMs: 50,
|
|
2810
|
+
// autoEnable defaults to false
|
|
2811
|
+
});
|
|
2812
|
+
|
|
2813
|
+
await hook.handleEvent({
|
|
2814
|
+
event: {
|
|
2815
|
+
type: 'session.idle',
|
|
2816
|
+
properties: { sessionID: 's1' },
|
|
2817
|
+
},
|
|
2818
|
+
});
|
|
2819
|
+
|
|
2820
|
+
await delay(60);
|
|
2821
|
+
|
|
2822
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
2823
|
+
});
|
|
2824
|
+
|
|
2825
|
+
test('auto-enable does not re-enable if already manually enabled', async () => {
|
|
2826
|
+
const ctx = createAutoEnableCtx([
|
|
2827
|
+
{ id: '1', content: 't1', status: 'pending', priority: 'high' },
|
|
2828
|
+
{ id: '2', content: 't2', status: 'pending', priority: 'high' },
|
|
2829
|
+
]);
|
|
2830
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2831
|
+
maxContinuations: 5,
|
|
2832
|
+
cooldownMs: 50,
|
|
2833
|
+
autoEnable: true,
|
|
2834
|
+
autoEnableThreshold: 4,
|
|
2835
|
+
});
|
|
2836
|
+
|
|
2837
|
+
// Manually enable first
|
|
2838
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2839
|
+
|
|
2840
|
+
// Only 2 todos (< threshold) - but already enabled, so should continue
|
|
2841
|
+
await hook.handleEvent({
|
|
2842
|
+
event: {
|
|
2843
|
+
type: 'session.idle',
|
|
2844
|
+
properties: { sessionID: 's1' },
|
|
2845
|
+
},
|
|
2846
|
+
});
|
|
2847
|
+
|
|
2848
|
+
await delay(60);
|
|
2849
|
+
|
|
2850
|
+
// Continues because already manually enabled (auto-enable check skipped)
|
|
2851
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2852
|
+
});
|
|
2853
|
+
|
|
2854
|
+
test('auto-enable respects custom threshold', async () => {
|
|
2855
|
+
const ctx = createAutoEnableCtx([
|
|
2856
|
+
{ id: '1', content: 't1', status: 'pending', priority: 'high' },
|
|
2857
|
+
{ id: '2', content: 't2', status: 'pending', priority: 'high' },
|
|
2858
|
+
]);
|
|
2859
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2860
|
+
maxContinuations: 5,
|
|
2861
|
+
cooldownMs: 50,
|
|
2862
|
+
autoEnable: true,
|
|
2863
|
+
autoEnableThreshold: 2,
|
|
2864
|
+
});
|
|
2865
|
+
|
|
2866
|
+
await hook.handleEvent({
|
|
2867
|
+
event: {
|
|
2868
|
+
type: 'session.idle',
|
|
2869
|
+
properties: { sessionID: 's1' },
|
|
2870
|
+
},
|
|
2871
|
+
});
|
|
2872
|
+
|
|
2873
|
+
await delay(60);
|
|
2874
|
+
|
|
2875
|
+
// 2 todos >= threshold 2 → auto-enables
|
|
2876
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2877
|
+
});
|
|
2878
|
+
|
|
2879
|
+
test('auto-enable skipped for non-orchestrator session', async () => {
|
|
2880
|
+
const ctx = createAutoEnableCtx([
|
|
2881
|
+
{ id: '1', content: 't1', status: 'pending', priority: 'high' },
|
|
2882
|
+
{ id: '2', content: 't2', status: 'pending', priority: 'high' },
|
|
2883
|
+
{ id: '3', content: 't3', status: 'pending', priority: 'high' },
|
|
2884
|
+
{ id: '4', content: 't4', status: 'pending', priority: 'high' },
|
|
2885
|
+
]);
|
|
2886
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2887
|
+
maxContinuations: 5,
|
|
2888
|
+
cooldownMs: 50,
|
|
2889
|
+
autoEnable: true,
|
|
2890
|
+
autoEnableThreshold: 4,
|
|
2891
|
+
});
|
|
2892
|
+
|
|
2893
|
+
// First idle sets orchestrator to session-A
|
|
2894
|
+
await hook.handleEvent({
|
|
2895
|
+
event: {
|
|
2896
|
+
type: 'session.idle',
|
|
2897
|
+
properties: { sessionID: 'session-A' },
|
|
2898
|
+
},
|
|
2899
|
+
});
|
|
2900
|
+
await delay(60);
|
|
2901
|
+
|
|
2902
|
+
// Reset mock
|
|
2903
|
+
ctx.client.session.prompt.mockClear();
|
|
2904
|
+
|
|
2905
|
+
// Second idle from session-B - not orchestrator, should skip
|
|
2906
|
+
await hook.handleEvent({
|
|
2907
|
+
event: {
|
|
2908
|
+
type: 'session.idle',
|
|
2909
|
+
properties: { sessionID: 'session-B' },
|
|
2910
|
+
},
|
|
2911
|
+
});
|
|
2912
|
+
await delay(60);
|
|
2913
|
+
|
|
2914
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
2915
|
+
});
|
|
2916
|
+
|
|
2917
|
+
test('auto-enable with todo fetch failure → no auto-enable, no crash', async () => {
|
|
2918
|
+
const ctx = createMockContext();
|
|
2919
|
+
ctx.client.session.todo = mock(async () => {
|
|
2920
|
+
throw new Error('Network error');
|
|
2921
|
+
});
|
|
2922
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2923
|
+
maxContinuations: 5,
|
|
2924
|
+
cooldownMs: 50,
|
|
2925
|
+
autoEnable: true,
|
|
2926
|
+
autoEnableThreshold: 4,
|
|
2927
|
+
});
|
|
2928
|
+
|
|
2929
|
+
// Should not throw
|
|
2930
|
+
await hook.handleEvent({
|
|
2931
|
+
event: {
|
|
2932
|
+
type: 'session.idle',
|
|
2933
|
+
properties: { sessionID: 's1' },
|
|
2934
|
+
},
|
|
2935
|
+
});
|
|
2936
|
+
|
|
2937
|
+
await delay(60);
|
|
2938
|
+
|
|
2939
|
+
// No auto-enable, no continuation
|
|
2940
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
2941
|
+
});
|
|
2942
|
+
|
|
2943
|
+
test('auto-enable resets consecutive counter and suppress window', async () => {
|
|
2944
|
+
const ctx = createAutoEnableCtx([
|
|
2945
|
+
{ id: '1', content: 't1', status: 'pending', priority: 'high' },
|
|
2946
|
+
{ id: '2', content: 't2', status: 'pending', priority: 'high' },
|
|
2947
|
+
{ id: '3', content: 't3', status: 'pending', priority: 'high' },
|
|
2948
|
+
{ id: '4', content: 't4', status: 'pending', priority: 'high' },
|
|
2949
|
+
]);
|
|
2950
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
2951
|
+
maxContinuations: 5,
|
|
2952
|
+
cooldownMs: 50,
|
|
2953
|
+
autoEnable: true,
|
|
2954
|
+
autoEnableThreshold: 4,
|
|
2955
|
+
});
|
|
2956
|
+
|
|
2957
|
+
// Manually enable, run a continuation, disable
|
|
2958
|
+
await hook.tool.auto_continue.execute({ enabled: true });
|
|
2959
|
+
await hook.handleEvent({
|
|
2960
|
+
event: {
|
|
2961
|
+
type: 'session.idle',
|
|
2962
|
+
properties: { sessionID: 's1' },
|
|
2963
|
+
},
|
|
2964
|
+
});
|
|
2965
|
+
await delay(60);
|
|
2966
|
+
|
|
2967
|
+
// Fire abort to set suppress window
|
|
2968
|
+
await hook.handleEvent({
|
|
2969
|
+
event: {
|
|
2970
|
+
type: 'session.error',
|
|
2971
|
+
properties: {
|
|
2972
|
+
sessionID: 's1',
|
|
2973
|
+
error: { name: 'AbortError' },
|
|
2974
|
+
},
|
|
2975
|
+
},
|
|
2976
|
+
});
|
|
2977
|
+
|
|
2978
|
+
// Disable
|
|
2979
|
+
await hook.tool.auto_continue.execute({ enabled: false });
|
|
2980
|
+
|
|
2981
|
+
// Reset mock
|
|
2982
|
+
ctx.client.session.prompt.mockClear();
|
|
2983
|
+
|
|
2984
|
+
// Fire idle again - auto-enable should trigger (4 todos >= 4),
|
|
2985
|
+
// resetting counter and suppress window
|
|
2986
|
+
await hook.handleEvent({
|
|
2987
|
+
event: {
|
|
2988
|
+
type: 'session.idle',
|
|
2989
|
+
properties: { sessionID: 's1' },
|
|
2990
|
+
},
|
|
2991
|
+
});
|
|
2992
|
+
|
|
2993
|
+
await delay(60);
|
|
2994
|
+
|
|
2995
|
+
// Should continue (suppressed window was cleared by auto-enable)
|
|
2996
|
+
expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
2997
|
+
});
|
|
2998
|
+
|
|
2999
|
+
test('auto-enable counts incomplete todos only, not completed', async () => {
|
|
3000
|
+
const ctx = createAutoEnableCtx([
|
|
3001
|
+
{ id: '1', content: 't1', status: 'completed', priority: 'high' },
|
|
3002
|
+
{ id: '2', content: 't2', status: 'completed', priority: 'high' },
|
|
3003
|
+
{ id: '3', content: 't3', status: 'pending', priority: 'high' },
|
|
3004
|
+
{ id: '4', content: 't4', status: 'pending', priority: 'high' },
|
|
3005
|
+
]);
|
|
3006
|
+
const hook = createTodoContinuationHook(ctx, {
|
|
3007
|
+
maxContinuations: 5,
|
|
3008
|
+
cooldownMs: 50,
|
|
3009
|
+
autoEnable: true,
|
|
3010
|
+
autoEnableThreshold: 4,
|
|
3011
|
+
});
|
|
3012
|
+
|
|
3013
|
+
await hook.handleEvent({
|
|
3014
|
+
event: {
|
|
3015
|
+
type: 'session.idle',
|
|
3016
|
+
properties: { sessionID: 's1' },
|
|
3017
|
+
},
|
|
3018
|
+
});
|
|
3019
|
+
|
|
3020
|
+
await delay(60);
|
|
3021
|
+
|
|
3022
|
+
// Only 2 incomplete todos < threshold 4 → does NOT auto-enable
|
|
3023
|
+
expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
3024
|
+
});
|
|
3025
|
+
});
|
|
3026
|
+
});
|