|
|
@@ -1,3127 +0,0 @@
|
|
|
-import { describe, expect, mock, test } from 'bun:test';
|
|
|
-import {
|
|
|
- BackgroundJobBoard,
|
|
|
- SLIM_INTERNAL_INITIATOR_MARKER,
|
|
|
-} from '../../utils';
|
|
|
-import { createTodoContinuationHook } from './index';
|
|
|
-import {
|
|
|
- TODO_FINAL_ACTIVE_REMINDER,
|
|
|
- TODO_HYGIENE_REMINDER,
|
|
|
-} from './todo-hygiene';
|
|
|
-
|
|
|
-describe('createTodoContinuationHook', () => {
|
|
|
- function createMockContext(overrides?: {
|
|
|
- todoResult?: {
|
|
|
- data?: Array<{
|
|
|
- id: string;
|
|
|
- content: string;
|
|
|
- status: string;
|
|
|
- priority: string;
|
|
|
- }>;
|
|
|
- };
|
|
|
- messagesResult?: {
|
|
|
- data?: Array<{
|
|
|
- info?: { role?: string };
|
|
|
- parts?: Array<{ type?: string; text?: string }>;
|
|
|
- }>;
|
|
|
- };
|
|
|
- }) {
|
|
|
- return {
|
|
|
- client: {
|
|
|
- session: {
|
|
|
- todo: mock(async () => overrides?.todoResult ?? { data: [] }),
|
|
|
- messages: mock(async () => overrides?.messagesResult ?? { data: [] }),
|
|
|
- prompt: mock(async () => ({})),
|
|
|
- },
|
|
|
- },
|
|
|
- } as any;
|
|
|
- }
|
|
|
-
|
|
|
- async function delay(ms: number): Promise<void> {
|
|
|
- await new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
- }
|
|
|
-
|
|
|
- // Notification prompts (noReply:true, no marker) fire immediately when
|
|
|
- // scheduling a continuation. These helpers check only for actual
|
|
|
- // continuation prompts (with SLIM_INTERNAL_INITIATOR_MARKER).
|
|
|
- function hasContinuation(m: ReturnType<typeof mock>): boolean {
|
|
|
- return m.mock.calls.some((c: any[]) =>
|
|
|
- (c[0]?.body?.parts as any[])?.some((p: any) =>
|
|
|
- p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
|
|
|
- ),
|
|
|
- );
|
|
|
- }
|
|
|
- function contCount(m: ReturnType<typeof mock>): number {
|
|
|
- return m.mock.calls.filter((c: any[]) =>
|
|
|
- (c[0]?.body?.parts as any[])?.some((p: any) =>
|
|
|
- p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
|
|
|
- ),
|
|
|
- ).length;
|
|
|
- }
|
|
|
- function contCall(m: ReturnType<typeof mock>): any[] {
|
|
|
- const call = m.mock.calls.find((c: any[]) =>
|
|
|
- (c[0]?.body?.parts as any[])?.some((p: any) =>
|
|
|
- p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
|
|
|
- ),
|
|
|
- );
|
|
|
- if (!call) {
|
|
|
- throw new Error('No continuation call found');
|
|
|
- }
|
|
|
- return call;
|
|
|
- }
|
|
|
-
|
|
|
- function userMessages(
|
|
|
- text: string,
|
|
|
- sessionID = 'main1',
|
|
|
- agent?: string,
|
|
|
- parts?: Array<{ type: string; text?: string }>,
|
|
|
- id?: string,
|
|
|
- ) {
|
|
|
- return {
|
|
|
- messages: [
|
|
|
- {
|
|
|
- info: { id, role: 'user', agent, sessionID },
|
|
|
- parts: parts ?? [{ type: 'text', text }],
|
|
|
- },
|
|
|
- ],
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- function allMessageText(output: {
|
|
|
- messages: Array<{ parts: Array<{ type?: string; text?: string }> }>;
|
|
|
- }) {
|
|
|
- return output.messages
|
|
|
- .flatMap((message) => message.parts)
|
|
|
- .filter((part) => part.type === 'text' && typeof part.text === 'string')
|
|
|
- .map((part) => part.text)
|
|
|
- .join('\n');
|
|
|
- }
|
|
|
-
|
|
|
- describe('tool toggle', () => {
|
|
|
- test('calling auto_continue execute with { enabled: true } sets state', async () => {
|
|
|
- const ctx = createMockContext();
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
-
|
|
|
- const result = await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- expect(result).toContain('Auto-continue enabled');
|
|
|
- expect(result).toContain('up to 5');
|
|
|
- });
|
|
|
-
|
|
|
- test('calling auto_continue execute with { enabled: false } disables', async () => {
|
|
|
- const ctx = createMockContext();
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
-
|
|
|
- const result = await hook.tool.auto_continue.execute({ enabled: false });
|
|
|
-
|
|
|
- expect(result).toBe('Auto-continue disabled.');
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('todo hygiene routing', () => {
|
|
|
- test('does not inject hygiene reminder for unknown non-orchestrator session', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const toolOutput = { output: 'task result' };
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(
|
|
|
- userMessages('continue previous work', 'sub1', 'explorer'),
|
|
|
- );
|
|
|
- await hook.handleToolExecuteAfter(
|
|
|
- { tool: 'task', sessionID: 'sub1' },
|
|
|
- toolOutput,
|
|
|
- );
|
|
|
-
|
|
|
- expect(toolOutput.output).toBe('task result');
|
|
|
- expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('does not expose a system transform handler', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 'todo1',
|
|
|
- status: 'in_progress',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
-
|
|
|
- expect('handleChatSystemTransform' in hook).toBe(false);
|
|
|
- });
|
|
|
-
|
|
|
- test('injects hygiene reminder into latest user message after todowrite activity', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const output = userMessages('primera request', 'main1', 'orchestrator');
|
|
|
- const toolOutput = { output: 'read result' };
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleToolExecuteAfter(
|
|
|
- { tool: 'read', sessionID: 'main1' },
|
|
|
- toolOutput,
|
|
|
- );
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
-
|
|
|
- expect(toolOutput.output).toBe('read result');
|
|
|
- expect(allMessageText(output)).toContain(TODO_HYGIENE_REMINDER);
|
|
|
- expect(allMessageText(output)).toContain(
|
|
|
- '<instruction name="todo_hygiene">',
|
|
|
- );
|
|
|
- });
|
|
|
-
|
|
|
- test('skips hygiene reminder when todo state lookup times out', async () => {
|
|
|
- const ctx = createMockContext();
|
|
|
- ctx.client.session.todo = mock(() => new Promise(() => {}));
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const output = userMessages('primera request', 'main1', 'orchestrator');
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
-
|
|
|
- expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('compaction-like transform does not consume pending reminder', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const live = userMessages('primera request', 'main1', 'orchestrator');
|
|
|
- const compactionClone = structuredClone(live);
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(live);
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(compactionClone);
|
|
|
- expect(allMessageText(compactionClone)).toContain(TODO_HYGIENE_REMINDER);
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(live);
|
|
|
- expect(allMessageText(live)).toContain(TODO_HYGIENE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('new request clears stale pending reminder state', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const first = userMessages('primera request', 'main1', 'orchestrator');
|
|
|
- const blocked = userMessages(
|
|
|
- 'segunda request distinta',
|
|
|
- 'main1',
|
|
|
- 'orchestrator',
|
|
|
- );
|
|
|
- const allowed = userMessages(
|
|
|
- 'segunda request distinta',
|
|
|
- 'main1',
|
|
|
- 'orchestrator',
|
|
|
- );
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(first);
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(blocked);
|
|
|
- expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
|
|
|
-
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
|
- await hook.handleMessagesTransform(allowed);
|
|
|
-
|
|
|
- expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('attachment-only requests reset stale state without synthetic text parts', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const first = userMessages('primera request', 'main1', 'orchestrator');
|
|
|
- const attachmentOnly = userMessages('', 'main1', 'orchestrator', [
|
|
|
- { type: 'image' },
|
|
|
- ]);
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(first);
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(attachmentOnly);
|
|
|
-
|
|
|
- expect(attachmentOnly.messages[0].parts).toHaveLength(1);
|
|
|
- expect(allMessageText(attachmentOnly)).not.toContain(
|
|
|
- TODO_HYGIENE_REMINDER,
|
|
|
- );
|
|
|
- });
|
|
|
-
|
|
|
- test('falls back to known orchestrator session when transform message lacks sessionID', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 'todo1',
|
|
|
- status: 'in_progress',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const output = {
|
|
|
- messages: [
|
|
|
- {
|
|
|
- info: { role: 'user', agent: 'orchestrator' },
|
|
|
- parts: [{ type: 'text', text: 'new request boundary' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- };
|
|
|
-
|
|
|
- hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
-
|
|
|
- expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('does not promote sessions with missing agent metadata to orchestrator', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const toolOutput = { output: 'task result' };
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(
|
|
|
- userMessages('continue previous work', 'sub1'),
|
|
|
- );
|
|
|
- await hook.handleToolExecuteAfter(
|
|
|
- { tool: 'task', sessionID: 'sub1' },
|
|
|
- toolOutput,
|
|
|
- );
|
|
|
-
|
|
|
- expect(toolOutput.output).toBe('task result');
|
|
|
- expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
|
|
|
- expect(toolOutput.output).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('known orchestrator sessions still process request boundaries when agent metadata is missing', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 'todo1',
|
|
|
- status: 'in_progress',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const output = userMessages('new request boundary', 'main1');
|
|
|
-
|
|
|
- hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
-
|
|
|
- expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('the same user message id consumes pending reminder even if array index shifts', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const shifted = {
|
|
|
- messages: [
|
|
|
- {
|
|
|
- info: { role: 'assistant', sessionID: 'main1' },
|
|
|
- parts: [{ type: 'text', text: 'intermediate output' }],
|
|
|
- },
|
|
|
- {
|
|
|
- info: {
|
|
|
- id: 'u1',
|
|
|
- role: 'user',
|
|
|
- agent: 'orchestrator',
|
|
|
- sessionID: 'main1',
|
|
|
- },
|
|
|
- parts: [{ type: 'text', text: 'request boundary' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- };
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(
|
|
|
- userMessages(
|
|
|
- 'request boundary',
|
|
|
- 'main1',
|
|
|
- 'orchestrator',
|
|
|
- undefined,
|
|
|
- 'u1',
|
|
|
- ),
|
|
|
- );
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
|
- await hook.handleMessagesTransform(shifted);
|
|
|
-
|
|
|
- expect(allMessageText(shifted)).toContain(TODO_HYGIENE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('a new user message id resets the request even if text is unchanged', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const blocked = userMessages(
|
|
|
- 'same text',
|
|
|
- 'main1',
|
|
|
- 'orchestrator',
|
|
|
- undefined,
|
|
|
- 'u2',
|
|
|
- );
|
|
|
- const allowed = userMessages(
|
|
|
- 'same text',
|
|
|
- 'main1',
|
|
|
- 'orchestrator',
|
|
|
- undefined,
|
|
|
- 'u2',
|
|
|
- );
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(
|
|
|
- userMessages('same text', 'main1', 'orchestrator', undefined, 'u1'),
|
|
|
- );
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(blocked);
|
|
|
- expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
|
|
|
-
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
|
- await hook.handleMessagesTransform(allowed);
|
|
|
-
|
|
|
- expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('a repeated text without message ids resets when a later user turn appears', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const blocked = {
|
|
|
- messages: [
|
|
|
- {
|
|
|
- info: { role: 'user', agent: 'orchestrator', sessionID: 'main1' },
|
|
|
- parts: [{ type: 'text', text: 'same text' }],
|
|
|
- },
|
|
|
- {
|
|
|
- info: { role: 'assistant', sessionID: 'main1' },
|
|
|
- parts: [{ type: 'text', text: 'intermediate output' }],
|
|
|
- },
|
|
|
- {
|
|
|
- info: { role: 'user', agent: 'orchestrator', sessionID: 'main1' },
|
|
|
- parts: [{ type: 'text', text: 'same text' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- };
|
|
|
- const allowed = structuredClone(blocked);
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(
|
|
|
- userMessages('same text', 'main1', 'orchestrator'),
|
|
|
- );
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(blocked);
|
|
|
- expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
|
|
|
-
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
|
- await hook.handleMessagesTransform(allowed);
|
|
|
-
|
|
|
- expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('messages without inferable sessionID clear stale state for known orchestrators', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const unknown = {
|
|
|
- messages: [
|
|
|
- {
|
|
|
- info: { role: 'user', agent: 'orchestrator' },
|
|
|
- parts: [{ type: 'text', text: 'boundary without session id' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- };
|
|
|
-
|
|
|
- hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
|
- hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
|
|
|
- await hook.handleMessagesTransform(
|
|
|
- userMessages('first request', 'main1', 'orchestrator', undefined, 'u1'),
|
|
|
- );
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(unknown);
|
|
|
-
|
|
|
- expect(allMessageText(unknown)).not.toContain(TODO_HYGIENE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('does not inject from continuation-like wording alone', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 'todo1',
|
|
|
- status: 'in_progress',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const toolOutput = { output: 'read result' };
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(
|
|
|
- userMessages(
|
|
|
- 'sigue este formato pero empieza de cero',
|
|
|
- 'main1',
|
|
|
- 'orchestrator',
|
|
|
- ),
|
|
|
- );
|
|
|
- await hook.handleToolExecuteAfter(
|
|
|
- { tool: 'read', sessionID: 'main1' },
|
|
|
- toolOutput,
|
|
|
- );
|
|
|
-
|
|
|
- expect(toolOutput.output).toBe('read result');
|
|
|
- expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
|
|
|
- expect(toolOutput.output).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('rearms on activity after todowrite even if request wording is continuation-like', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 'todo1',
|
|
|
- status: 'in_progress',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const output = userMessages(
|
|
|
- 'finish the previous work',
|
|
|
- 'main1',
|
|
|
- 'orchestrator',
|
|
|
- );
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
-
|
|
|
- expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('final active todo after todowrite uses the stronger finishing reminder', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 'todo1',
|
|
|
- status: 'in_progress',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const output = userMessages('haz esto', 'main1', 'orchestrator');
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
-
|
|
|
- expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
|
|
|
- expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
|
|
|
- });
|
|
|
-
|
|
|
- test('todo hygiene reminder includes unreconciled background results', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 'todo1',
|
|
|
- status: 'in_progress',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const board = new BackgroundJobBoard();
|
|
|
- board.registerLaunch({
|
|
|
- taskID: 'child-1',
|
|
|
- parentSessionID: 'main1',
|
|
|
- agent: 'oracle',
|
|
|
- description: 'review plan',
|
|
|
- });
|
|
|
- board.updateStatus({ taskID: 'child-1', state: 'completed' });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- backgroundJobBoard: board,
|
|
|
- });
|
|
|
- const output = userMessages('haz esto', 'main1', 'orchestrator');
|
|
|
-
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
- await hook.handleToolExecuteAfter({
|
|
|
- tool: 'todowrite',
|
|
|
- sessionID: 'main1',
|
|
|
- });
|
|
|
- await hook.handleMessagesTransform(output);
|
|
|
-
|
|
|
- expect(allMessageText(output)).toContain(
|
|
|
- 'Background jobs have terminal results: reconcile the Background Job Board results before finalizing.',
|
|
|
- );
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('continuation scheduling', () => {
|
|
|
- test('session idle + enabled + incomplete todos → schedules continuation', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- { id: '2', content: 'todo2', status: 'completed', priority: 'low' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Here is the result' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 50,
|
|
|
- });
|
|
|
-
|
|
|
- // Enable auto-continue
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Fire session.idle event
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Wait for cooldown
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Verify session.prompt was called with continuation prompt
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- const promptCall = contCall(ctx.client.session.prompt);
|
|
|
- expect(promptCall[0].path.id).toBe('session-123');
|
|
|
- expect(promptCall[0].body.parts[0].text).toContain(
|
|
|
- '[Auto-continue: enabled - there are incomplete todos remaining.',
|
|
|
- );
|
|
|
- expect(promptCall[0].body.parts[0].text).toContain(
|
|
|
- SLIM_INTERNAL_INITIATOR_MARKER,
|
|
|
- );
|
|
|
- });
|
|
|
-
|
|
|
- test('continuation prompt nudges toward running background job status', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Continuing' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const board = new BackgroundJobBoard();
|
|
|
- board.registerLaunch({
|
|
|
- taskID: 'child-1',
|
|
|
- parentSessionID: 'session-123',
|
|
|
- agent: 'fixer',
|
|
|
- description: 'implement change',
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- cooldownMs: 50,
|
|
|
- backgroundJobBoard: board,
|
|
|
- });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- const promptCall = contCall(ctx.client.session.prompt);
|
|
|
- expect(promptCall[0].body.parts[0].text).toContain(
|
|
|
- 'Background jobs are still running: call task_status',
|
|
|
- );
|
|
|
- });
|
|
|
-
|
|
|
- test('disabled → no continuation', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Done' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- // Do NOT enable auto-continue
|
|
|
-
|
|
|
- // Fire session.idle event
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Wait for cooldown
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Verify session.prompt was NOT called
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('last message is a question → skip', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [
|
|
|
- { type: 'text', text: 'Should I proceed with the next step?' },
|
|
|
- ],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- // Enable auto-continue
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Fire session.idle event
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Wait for cooldown
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Verify continuation NOT scheduled
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('question detection with question mark → skip', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Ready to continue?' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('question detection with "would you like" phrase → skip', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [
|
|
|
- {
|
|
|
- type: 'text',
|
|
|
- text: 'Would you like me to proceed?',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('max continuations reached → skip', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 2,
|
|
|
- cooldownMs: 50,
|
|
|
- });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Fire idle events up to maxContinuations
|
|
|
- for (let i = 0; i < 2; i++) {
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
- }
|
|
|
-
|
|
|
- // Reset mock for the 3rd attempt
|
|
|
- ctx.client.session.prompt.mockClear();
|
|
|
-
|
|
|
- // On the N+1th idle, verify no continuation scheduled
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('abort suppress window → skip', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- // Seed orchestrator session
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Fire session.error with MessageAbortedError
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.error',
|
|
|
- properties: {
|
|
|
- sessionID: 'session-123',
|
|
|
- error: { name: 'MessageAbortedError' },
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Immediately fire session.idle
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Wait less than suppress window (5s) - just enough to verify it's working
|
|
|
- await delay(100);
|
|
|
-
|
|
|
- // Verify no continuation within suppress window
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('session busy → cancel pending timer', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 500,
|
|
|
- });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Schedule a continuation
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // After the notification grace but before cooldown expires, fire busy.
|
|
|
- await delay(300);
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.status',
|
|
|
- properties: {
|
|
|
- sessionID: 'session-123',
|
|
|
- status: { type: 'busy' },
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Advance past original cooldown
|
|
|
- await delay(250);
|
|
|
-
|
|
|
- // Verify timer was cancelled and prompt NOT called
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
|
- });
|
|
|
-
|
|
|
- test('sub-agent session.busy does NOT cancel orchestrator timer', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 100,
|
|
|
- });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Schedule a continuation for orchestrator session
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // A sub-agent (different session) goes busy
|
|
|
- await delay(50);
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.status',
|
|
|
- properties: {
|
|
|
- sessionID: 'sub-agent-456',
|
|
|
- status: { type: 'busy' },
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Advance past original cooldown
|
|
|
- await delay(250);
|
|
|
-
|
|
|
- // Orchestrator timer should still fire — prompt was called
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
-
|
|
|
- test('all todos complete → skip', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 'todo1',
|
|
|
- status: 'completed',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- { id: '2', content: 'todo2', status: 'cancelled', priority: 'low' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'All done' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('non-orchestrator session → skip', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // First idle from session A (becomes orchestrator)
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-A' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Verify prompt was called for session A
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
-
|
|
|
- // Reset mock
|
|
|
- ctx.client.session.prompt.mockClear();
|
|
|
-
|
|
|
- // Second idle from session B (different sessionID)
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-B' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Verify no continuation for session B
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('cooldownMs from config', async () => {
|
|
|
- const customCooldownMs = 150;
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: customCooldownMs,
|
|
|
- });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Advance timer by well under the custom cooldown to avoid timer jitter
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Verify prompt not called yet
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
|
-
|
|
|
- // Advance timer past the configured cooldown
|
|
|
- await delay(100);
|
|
|
-
|
|
|
- // Now prompt should be called
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('event handling - session.error', () => {
|
|
|
- test('MessageAbortedError sets suppress window', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- // Seed orchestrator session
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Fire session.error with MessageAbortedError
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.error',
|
|
|
- properties: {
|
|
|
- sessionID: 'session-123',
|
|
|
- error: { name: 'MessageAbortedError' },
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Wait less than suppress window
|
|
|
- await delay(100);
|
|
|
-
|
|
|
- // Verify no continuation within suppress window
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('AbortError sets suppress window', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- // Seed orchestrator session (disabled, so no continuation fires)
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.error',
|
|
|
- properties: {
|
|
|
- sessionID: 'session-123',
|
|
|
- error: { name: 'AbortError' },
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Wait less than suppress window
|
|
|
- await delay(100);
|
|
|
-
|
|
|
- // Verify no continuation within suppress window
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('other errors do not set suppress window', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.error',
|
|
|
- properties: {
|
|
|
- sessionID: 'session-123',
|
|
|
- error: { name: 'NetworkError' },
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Prompt should be called immediately (no suppress window)
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('event handling - session.deleted', () => {
|
|
|
- test('clears pending timer on session delete', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 100,
|
|
|
- });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Schedule continuation
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Delete session before timer fires
|
|
|
- await delay(50);
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.deleted',
|
|
|
- properties: {
|
|
|
- sessionID: 'session-123',
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Advance past original cooldown
|
|
|
- await delay(250);
|
|
|
-
|
|
|
- // Verify timer was cancelled and prompt NOT called
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
|
- });
|
|
|
-
|
|
|
- test('sub-agent session.deleted does NOT cancel orchestrator timer', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 100,
|
|
|
- });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Schedule continuation for orchestrator session
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // A sub-agent (different session) gets deleted
|
|
|
- await delay(50);
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.deleted',
|
|
|
- properties: {
|
|
|
- sessionID: 'sub-agent-456',
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Advance past original cooldown
|
|
|
- await delay(250);
|
|
|
-
|
|
|
- // Orchestrator timer should still fire — prompt was called
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
-
|
|
|
- test('resets orchestrator session when deleted session matches', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // First idle sets orchestrator
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-A' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Delete orchestrator session
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.deleted',
|
|
|
- properties: {
|
|
|
- sessionID: 'session-A',
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Second idle from new session should become orchestrator
|
|
|
- ctx.client.session.prompt.mockClear();
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-B' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Prompt should be called for session-B (new orchestrator)
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- const promptCall = contCall(ctx.client.session.prompt);
|
|
|
- expect(promptCall[0].path.id).toBe('session-B');
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('error handling', () => {
|
|
|
- test('fetch todos failure → skips continuation', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: undefined as any,
|
|
|
- });
|
|
|
- ctx.client.session.todo = mock(async () => {
|
|
|
- throw new Error('Failed to fetch todos');
|
|
|
- });
|
|
|
-
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('fetch messages failure → skips continuation', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- ctx.client.session.messages = mock(async () => {
|
|
|
- throw new Error('Failed to fetch messages');
|
|
|
- });
|
|
|
-
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('command.execute.before interception', () => {
|
|
|
- test('unrelated command → no interception', async () => {
|
|
|
- const ctx = createMockContext();
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const output = { parts: [] as Array<{ type: string; text?: string }> };
|
|
|
-
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- { command: 'help', sessionID: 'session-123', arguments: '' },
|
|
|
- output,
|
|
|
- );
|
|
|
-
|
|
|
- expect(output.parts).toHaveLength(0);
|
|
|
- });
|
|
|
-
|
|
|
- test('/auto-continue enables and injects continuation when incomplete todos', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 'todo1',
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const output = { parts: [] as Array<{ type: string; text?: string }> };
|
|
|
-
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
|
|
|
- output,
|
|
|
- );
|
|
|
-
|
|
|
- expect(output.parts).toHaveLength(1);
|
|
|
- expect(output.parts[0].text).toContain(
|
|
|
- '[Auto-continue: enabled - there are incomplete todos remaining.',
|
|
|
- );
|
|
|
- expect(output.parts[0].text).toContain(SLIM_INTERNAL_INITIATOR_MARKER);
|
|
|
- });
|
|
|
-
|
|
|
- test('/auto-continue enables but no continuation when all todos complete', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 'todo1',
|
|
|
- status: 'completed',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const output = { parts: [] as Array<{ type: string; text?: string }> };
|
|
|
-
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
|
|
|
- output,
|
|
|
- );
|
|
|
-
|
|
|
- expect(output.parts).toHaveLength(1);
|
|
|
- expect(output.parts[0].text).toContain('No incomplete todos right now');
|
|
|
- });
|
|
|
-
|
|
|
- test('/auto-continue toggles off when already enabled', async () => {
|
|
|
- const ctx = createMockContext();
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const output = { parts: [] as Array<{ type: string; text?: string }> };
|
|
|
-
|
|
|
- // Enable via tool
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Toggle off via command
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
|
|
|
- output,
|
|
|
- );
|
|
|
-
|
|
|
- expect(output.parts).toHaveLength(1);
|
|
|
- expect(output.parts[0].text).toContain('disabled by user command');
|
|
|
- });
|
|
|
-
|
|
|
- test('/auto-continue resets consecutive continuations on toggle', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 'todo1',
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 2,
|
|
|
- cooldownMs: 50,
|
|
|
- });
|
|
|
-
|
|
|
- // Enable and run up to max
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
- for (let i = 0; i < 2; i++) {
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
- }
|
|
|
-
|
|
|
- // Toggle off then on via command (resets count)
|
|
|
- const outputOff = {
|
|
|
- parts: [] as Array<{ type: string; text?: string }>,
|
|
|
- };
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
|
|
|
- outputOff,
|
|
|
- );
|
|
|
- expect(outputOff.parts[0].text).toContain('disabled');
|
|
|
-
|
|
|
- const outputOn = {
|
|
|
- parts: [] as Array<{ type: string; text?: string }>,
|
|
|
- };
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
|
|
|
- outputOn,
|
|
|
- );
|
|
|
- // Should have continuation prompt again (count was reset)
|
|
|
- expect(outputOn.parts[0].text).toContain(
|
|
|
- '[Auto-continue: enabled - there are incomplete todos remaining.',
|
|
|
- );
|
|
|
- });
|
|
|
-
|
|
|
- test('/auto-continue with todo fetch failure → enables without continuation', async () => {
|
|
|
- const ctx = createMockContext();
|
|
|
- ctx.client.session.todo = mock(async () => {
|
|
|
- throw new Error('Network error');
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- const output = { parts: [] as Array<{ type: string; text?: string }> };
|
|
|
-
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
|
|
|
- output,
|
|
|
- );
|
|
|
-
|
|
|
- // Should still enable but skip continuation (no todos fetched)
|
|
|
- expect(output.parts).toHaveLength(1);
|
|
|
- expect(output.parts[0].text).toContain('No incomplete todos right now');
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('config defaults', () => {
|
|
|
- test('default config: maxContinuations = 5, cooldownMs = 3000', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx); // No config passed
|
|
|
-
|
|
|
- const result = await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- expect(result).toContain('up to 5');
|
|
|
-
|
|
|
- // Test default cooldown - we'll just verify it waits before calling
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-123' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Wait less than default cooldown
|
|
|
- await delay(100);
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
|
-
|
|
|
- // Wait past default cooldown
|
|
|
- await delay(2900);
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('council review findings', () => {
|
|
|
- describe('CRITICAL-1: counter bypass via session.status→busy', () => {
|
|
|
- test('counter persists when busy fires during auto-injection', async () => {
|
|
|
- let promptResolve!: () => void;
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 't1',
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Work' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Make prompt hang so isAutoInjecting stays true
|
|
|
- ctx.client.session.prompt = mock(async () => {
|
|
|
- await new Promise<void>((r) => {
|
|
|
- promptResolve = r;
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 2,
|
|
|
- cooldownMs: 50,
|
|
|
- });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Cycle 1: idle → timer → prompt hangs
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Session goes busy from prompt — isAutoInjecting is true,
|
|
|
- // so counter should NOT be reset
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.status',
|
|
|
- properties: {
|
|
|
- sessionID: 's1',
|
|
|
- status: { type: 'busy' },
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Resolve prompt → counter = 1
|
|
|
- promptResolve();
|
|
|
- await delay(10);
|
|
|
-
|
|
|
- // Cycle 2: idle → timer → prompt hangs
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Session goes busy again — counter still not reset
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.status',
|
|
|
- properties: {
|
|
|
- sessionID: 's1',
|
|
|
- status: { type: 'busy' },
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Resolve prompt → counter = 2
|
|
|
- promptResolve();
|
|
|
- await delay(10);
|
|
|
-
|
|
|
- // Cycle 3: counter = 2 >= maxContinuations = 2 → BLOCKED
|
|
|
- ctx.client.session.prompt = mock(async () => ({}));
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('CRITICAL-2: disable cancels pending timer', () => {
|
|
|
- test('tool disable during cooldown prevents injection', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 't1',
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Work' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 100 });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Fire idle → timer scheduled (100ms cooldown)
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Disable before timer fires
|
|
|
- await delay(50);
|
|
|
- await hook.tool.auto_continue.execute({ enabled: false });
|
|
|
-
|
|
|
- // Wait past original cooldown
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
|
- });
|
|
|
-
|
|
|
- test('command disable during cooldown prevents injection', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 't1',
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Work' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 100 });
|
|
|
-
|
|
|
- // Enable via command
|
|
|
- const outputOn = {
|
|
|
- parts: [] as Array<{ type: string; text?: string }>,
|
|
|
- };
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- {
|
|
|
- command: 'auto-continue',
|
|
|
- sessionID: 's1',
|
|
|
- arguments: 'on',
|
|
|
- },
|
|
|
- outputOn,
|
|
|
- );
|
|
|
-
|
|
|
- // Fire idle → timer scheduled
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Disable via command before timer fires
|
|
|
- await delay(50);
|
|
|
- const outputOff = {
|
|
|
- parts: [] as Array<{ type: string; text?: string }>,
|
|
|
- };
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- {
|
|
|
- command: 'auto-continue',
|
|
|
- sessionID: 's1',
|
|
|
- arguments: 'off',
|
|
|
- },
|
|
|
- outputOff,
|
|
|
- );
|
|
|
-
|
|
|
- // Wait past original cooldown
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('MAJOR-1: session.deleted resets counter', () => {
|
|
|
- test('deleted orchestrator session resets counter for next session', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 't1',
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Work' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 2,
|
|
|
- cooldownMs: 50,
|
|
|
- });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Cycle 1: idle → inject → counter = 1
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Delete orchestrator session → counter should reset
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.deleted',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // New session becomes orchestrator — counter starts from 0
|
|
|
- ctx.client.session.prompt.mockClear();
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's2' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60); // counter = 1
|
|
|
-
|
|
|
- // One more cycle → counter = 2 (reaches max)
|
|
|
- ctx.client.session.prompt.mockClear();
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's2' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Third cycle blocked (counter = 2 >= max = 2)
|
|
|
- ctx.client.session.prompt.mockClear();
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's2' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('MAJOR-2: suppressUntil cleared on re-enable', () => {
|
|
|
- test('tool re-enable clears suppress window', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 't1',
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Work' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Fire abort → sets suppress window
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.error',
|
|
|
- properties: {
|
|
|
- sessionID: 's1',
|
|
|
- error: { name: 'AbortError' },
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Re-enable within suppress window → clears suppressUntil
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Fire idle → should NOT be suppressed
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
-
|
|
|
- test('command re-enable clears suppress window', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 't1',
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Work' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Fire abort → sets suppress window
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.error',
|
|
|
- properties: {
|
|
|
- sessionID: 's1',
|
|
|
- error: { name: 'AbortError' },
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Re-enable via command → clears suppressUntil
|
|
|
- const output = {
|
|
|
- parts: [] as Array<{ type: string; text?: string }>,
|
|
|
- };
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- {
|
|
|
- command: 'auto-continue',
|
|
|
- sessionID: 's1',
|
|
|
- arguments: 'on',
|
|
|
- },
|
|
|
- output,
|
|
|
- );
|
|
|
-
|
|
|
- // Fire idle → should NOT be suppressed
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('error paths', () => {
|
|
|
- test('prompt failure in timer callback is handled gracefully', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 't1',
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Work' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- ctx.client.session.prompt = mock(async () => {
|
|
|
- throw new Error('API error');
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
-
|
|
|
- // Seed orchestrator session
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Error caught; isAutoInjecting should be cleared via finally.
|
|
|
- // Verify by checking a second idle still works.
|
|
|
- ctx.client.session.prompt = mock(async () => ({}));
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('edge cases', () => {
|
|
|
- test('session.idle with missing sessionID returns early', async () => {
|
|
|
- const ctx = createMockContext();
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Fire idle without sessionID — should not throw
|
|
|
- await hook.handleEvent({
|
|
|
- event: { type: 'session.idle', properties: {} },
|
|
|
- });
|
|
|
-
|
|
|
- expect(ctx.client.session.todo).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('session.deleted with properties.info.id path', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 't1',
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Work' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Set orchestrator via idle
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
-
|
|
|
- // Delete via info.id path (alternative shape from session store)
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.deleted',
|
|
|
- properties: { info: { id: 's1' } },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // New session should become orchestrator
|
|
|
- ctx.client.session.prompt.mockClear();
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's2' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('s2');
|
|
|
- });
|
|
|
-
|
|
|
- test('cooldownMs = 0 fires on next tick', async () => {
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 't1',
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Work' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- cooldownMs: 0,
|
|
|
- maxContinuations: 5,
|
|
|
- });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(10);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('MAJOR-3: double-fire prevention', () => {
|
|
|
- test('rapid idle events during prompt delivery — single continuation', async () => {
|
|
|
- let promptResolve!: () => void;
|
|
|
- const ctx = createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- content: 't1',
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Work' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- ctx.client.session.prompt = mock(async () => {
|
|
|
- await new Promise<void>((r) => {
|
|
|
- promptResolve = r;
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Fire idle → timer → prompt hangs (isAutoInjecting = true)
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Fire another idle while prompt is in flight
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Only one prompt call (blocked by isAutoInjecting gate)
|
|
|
- expect(contCount(ctx.client.session.prompt)).toBe(1);
|
|
|
-
|
|
|
- // Resolve prompt
|
|
|
- promptResolve();
|
|
|
- await delay(10);
|
|
|
-
|
|
|
- // Now idle should schedule a new timer
|
|
|
- ctx.client.session.prompt = mock(async () => ({}));
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(contCount(ctx.client.session.prompt)).toBe(1);
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('MAJOR-4: command explicit on|off arguments', () => {
|
|
|
- test('command "on" keeps enabled state when already enabled', async () => {
|
|
|
- const ctx = createMockContext();
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
-
|
|
|
- // Enable via tool
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // /auto-continue on → should KEEP enabled (not toggle to off)
|
|
|
- const output = {
|
|
|
- parts: [] as Array<{ type: string; text?: string }>,
|
|
|
- };
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- {
|
|
|
- command: 'auto-continue',
|
|
|
- sessionID: 's1',
|
|
|
- arguments: 'on',
|
|
|
- },
|
|
|
- output,
|
|
|
- );
|
|
|
-
|
|
|
- expect(output.parts[0].text).not.toContain('disabled');
|
|
|
- });
|
|
|
-
|
|
|
- test('command "off" keeps disabled state when already disabled', async () => {
|
|
|
- const ctx = createMockContext();
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
-
|
|
|
- // Start disabled (default)
|
|
|
- const output = {
|
|
|
- parts: [] as Array<{ type: string; text?: string }>,
|
|
|
- };
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- {
|
|
|
- command: 'auto-continue',
|
|
|
- sessionID: 's1',
|
|
|
- arguments: 'off',
|
|
|
- },
|
|
|
- output,
|
|
|
- );
|
|
|
-
|
|
|
- expect(output.parts[0].text).toContain('disabled');
|
|
|
- });
|
|
|
-
|
|
|
- test('command with no argument toggles state', async () => {
|
|
|
- const ctx = createMockContext();
|
|
|
- const hook = createTodoContinuationHook(ctx);
|
|
|
-
|
|
|
- // First toggle: disabled → enabled
|
|
|
- const output1 = {
|
|
|
- parts: [] as Array<{ type: string; text?: string }>,
|
|
|
- };
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- {
|
|
|
- command: 'auto-continue',
|
|
|
- sessionID: 's1',
|
|
|
- arguments: '',
|
|
|
- },
|
|
|
- output1,
|
|
|
- );
|
|
|
- expect(output1.parts[0].text).not.toContain('disabled');
|
|
|
-
|
|
|
- // Second toggle: enabled → disabled
|
|
|
- const output2 = {
|
|
|
- parts: [] as Array<{ type: string; text?: string }>,
|
|
|
- };
|
|
|
- await hook.handleCommandExecuteBefore(
|
|
|
- {
|
|
|
- command: 'auto-continue',
|
|
|
- sessionID: 's1',
|
|
|
- arguments: '',
|
|
|
- },
|
|
|
- output2,
|
|
|
- );
|
|
|
- expect(output2.parts[0].text).toContain('disabled');
|
|
|
- });
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('session routing and notification cancellation', () => {
|
|
|
- function createPendingCtx() {
|
|
|
- return createMockContext({
|
|
|
- todoResult: {
|
|
|
- data: [
|
|
|
- { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
|
|
|
- ],
|
|
|
- },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Work in progress' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- test('chat.message registers orchestrator sessions without first-idle lockout', async () => {
|
|
|
- const ctx = createPendingCtx();
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- cooldownMs: 50,
|
|
|
- });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- hook.handleChatMessage({ sessionID: 'sub1', agent: 'fixer' });
|
|
|
- hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
|
- hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'sub1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'main2' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('main2');
|
|
|
- });
|
|
|
-
|
|
|
- test('chat.message without agent does not block legacy first-idle fallback', async () => {
|
|
|
- const ctx = createPendingCtx();
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- cooldownMs: 50,
|
|
|
- autoEnable: true,
|
|
|
- autoEnableThreshold: 1,
|
|
|
- });
|
|
|
-
|
|
|
- hook.handleChatMessage({ sessionID: 'main1' });
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'main1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
-
|
|
|
- test('subagent chat.message prevents first-idle fallback registration', async () => {
|
|
|
- const ctx = createPendingCtx();
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- cooldownMs: 50,
|
|
|
- autoEnable: true,
|
|
|
- autoEnableThreshold: 1,
|
|
|
- });
|
|
|
-
|
|
|
- hook.handleChatMessage({ sessionID: 'sub1', agent: 'fixer' });
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'sub1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('session.status idle triggers continuation like session.idle', async () => {
|
|
|
- const ctx = createPendingCtx();
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
- hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.status',
|
|
|
- properties: { sessionID: 'main1', status: { type: 'idle' } },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
-
|
|
|
- test('deleting another orchestrator does not cancel the active session timer', async () => {
|
|
|
- const ctx = createPendingCtx();
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
- hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
|
- hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'main1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.deleted',
|
|
|
- properties: { sessionID: 'main2' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('main1');
|
|
|
- });
|
|
|
-
|
|
|
- test('deleting all orchestrators restores legacy first-idle fallback', async () => {
|
|
|
- const ctx = createPendingCtx();
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- cooldownMs: 50,
|
|
|
- autoEnable: true,
|
|
|
- autoEnableThreshold: 1,
|
|
|
- });
|
|
|
- hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
|
- hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.deleted',
|
|
|
- properties: { sessionID: 'main1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.deleted',
|
|
|
- properties: { sessionID: 'main2' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'legacy-main' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- expect(contCall(ctx.client.session.prompt)[0].path.id).toBe(
|
|
|
- 'legacy-main',
|
|
|
- );
|
|
|
- });
|
|
|
-
|
|
|
- test('countdown notification busy status does not reset max-continuation counter', async () => {
|
|
|
- const ctx = createPendingCtx();
|
|
|
- const releaseNotifications: Array<() => void> = [];
|
|
|
- ctx.client.session.prompt = mock(async (args: any) => {
|
|
|
- if (args?.body?.noReply === true) {
|
|
|
- await new Promise<void>((resolve) => {
|
|
|
- releaseNotifications.push(resolve);
|
|
|
- });
|
|
|
- }
|
|
|
- return {};
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- cooldownMs: 50,
|
|
|
- maxContinuations: 2,
|
|
|
- });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
- hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
|
-
|
|
|
- for (let i = 0; i < 2; i++) {
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'main1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.status',
|
|
|
- properties: { sessionID: 'main1', status: { type: 'busy' } },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
- releaseNotifications.shift()?.();
|
|
|
- await delay(10);
|
|
|
- }
|
|
|
-
|
|
|
- ctx.client.session.prompt.mockClear();
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'main1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('late countdown notification busy status does not cancel continuation timer', async () => {
|
|
|
- const ctx = createPendingCtx();
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
- hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'main1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(10);
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.status',
|
|
|
- properties: { sessionID: 'main1', status: { type: 'busy' } },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
-
|
|
|
- test('countdown notification busy status does not cancel continuation timer', async () => {
|
|
|
- const ctx = createPendingCtx();
|
|
|
- let callCount = 0;
|
|
|
- ctx.client.session.prompt = mock(async () => {
|
|
|
- callCount++;
|
|
|
- return {};
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
- hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'main1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.status',
|
|
|
- properties: { sessionID: 'main1', status: { type: 'busy' } },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(callCount).toBeGreaterThanOrEqual(2);
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('auto-enable on todo count', () => {
|
|
|
- function createAutoEnableCtx(
|
|
|
- todos: Array<{
|
|
|
- id: string;
|
|
|
- content: string;
|
|
|
- status: string;
|
|
|
- priority: string;
|
|
|
- }>,
|
|
|
- ) {
|
|
|
- return createMockContext({
|
|
|
- todoResult: { data: todos },
|
|
|
- messagesResult: {
|
|
|
- data: [
|
|
|
- {
|
|
|
- info: { role: 'assistant' },
|
|
|
- parts: [{ type: 'text', text: 'Working...' }],
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- test('autoEnable=true, todos >= threshold → auto-enables and continues', async () => {
|
|
|
- const ctx = createAutoEnableCtx([
|
|
|
- { id: '1', content: 't1', status: 'pending', priority: 'high' },
|
|
|
- { id: '2', content: 't2', status: 'pending', priority: 'high' },
|
|
|
- { id: '3', content: 't3', status: 'pending', priority: 'high' },
|
|
|
- { id: '4', content: 't4', status: 'pending', priority: 'high' },
|
|
|
- ]);
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 50,
|
|
|
- autoEnable: true,
|
|
|
- autoEnableThreshold: 4,
|
|
|
- });
|
|
|
-
|
|
|
- // Do NOT manually enable — auto-enable should trigger
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Should have scheduled continuation (auto-enabled)
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
-
|
|
|
- test('autoEnable=true, todos < threshold → does NOT auto-enable', async () => {
|
|
|
- const ctx = createAutoEnableCtx([
|
|
|
- { id: '1', content: 't1', status: 'pending', priority: 'high' },
|
|
|
- { id: '2', content: 't2', status: 'pending', priority: 'high' },
|
|
|
- { id: '3', content: 't3', status: 'pending', priority: 'high' },
|
|
|
- ]);
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 50,
|
|
|
- autoEnable: true,
|
|
|
- autoEnableThreshold: 4,
|
|
|
- });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Should NOT auto-enable or continue
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('autoEnable=false (default) → never auto-enables regardless of todo count', async () => {
|
|
|
- const ctx = createAutoEnableCtx(
|
|
|
- Array.from({ length: 10 }, (_, i) => ({
|
|
|
- id: String(i),
|
|
|
- content: `t${i}`,
|
|
|
- status: 'pending',
|
|
|
- priority: 'high',
|
|
|
- })),
|
|
|
- );
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 50,
|
|
|
- // autoEnable defaults to false
|
|
|
- });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('auto-enable does not re-enable if already manually enabled', async () => {
|
|
|
- const ctx = createAutoEnableCtx([
|
|
|
- { id: '1', content: 't1', status: 'pending', priority: 'high' },
|
|
|
- { id: '2', content: 't2', status: 'pending', priority: 'high' },
|
|
|
- ]);
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 50,
|
|
|
- autoEnable: true,
|
|
|
- autoEnableThreshold: 4,
|
|
|
- });
|
|
|
-
|
|
|
- // Manually enable first
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
-
|
|
|
- // Only 2 todos (< threshold) — but already enabled, so should continue
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Continues because already manually enabled (auto-enable check skipped)
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
-
|
|
|
- test('auto-enable respects custom threshold', async () => {
|
|
|
- const ctx = createAutoEnableCtx([
|
|
|
- { id: '1', content: 't1', status: 'pending', priority: 'high' },
|
|
|
- { id: '2', content: 't2', status: 'pending', priority: 'high' },
|
|
|
- ]);
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 50,
|
|
|
- autoEnable: true,
|
|
|
- autoEnableThreshold: 2,
|
|
|
- });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // 2 todos >= threshold 2 → auto-enables
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
-
|
|
|
- test('auto-enable skipped for non-orchestrator session', async () => {
|
|
|
- const ctx = createAutoEnableCtx([
|
|
|
- { id: '1', content: 't1', status: 'pending', priority: 'high' },
|
|
|
- { id: '2', content: 't2', status: 'pending', priority: 'high' },
|
|
|
- { id: '3', content: 't3', status: 'pending', priority: 'high' },
|
|
|
- { id: '4', content: 't4', status: 'pending', priority: 'high' },
|
|
|
- ]);
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 50,
|
|
|
- autoEnable: true,
|
|
|
- autoEnableThreshold: 4,
|
|
|
- });
|
|
|
-
|
|
|
- // First idle sets orchestrator to session-A
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-A' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Reset mock
|
|
|
- ctx.client.session.prompt.mockClear();
|
|
|
-
|
|
|
- // Second idle from session-B — not orchestrator, should skip
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 'session-B' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('auto-enable with todo fetch failure → no auto-enable, no crash', async () => {
|
|
|
- const ctx = createMockContext();
|
|
|
- ctx.client.session.todo = mock(async () => {
|
|
|
- throw new Error('Network error');
|
|
|
- });
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 50,
|
|
|
- autoEnable: true,
|
|
|
- autoEnableThreshold: 4,
|
|
|
- });
|
|
|
-
|
|
|
- // Should not throw
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // No auto-enable, no continuation
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
-
|
|
|
- test('auto-enable resets consecutive counter and suppress window', async () => {
|
|
|
- const ctx = createAutoEnableCtx([
|
|
|
- { id: '1', content: 't1', status: 'pending', priority: 'high' },
|
|
|
- { id: '2', content: 't2', status: 'pending', priority: 'high' },
|
|
|
- { id: '3', content: 't3', status: 'pending', priority: 'high' },
|
|
|
- { id: '4', content: 't4', status: 'pending', priority: 'high' },
|
|
|
- ]);
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 50,
|
|
|
- autoEnable: true,
|
|
|
- autoEnableThreshold: 4,
|
|
|
- });
|
|
|
-
|
|
|
- // Manually enable, run a continuation, disable
|
|
|
- await hook.tool.auto_continue.execute({ enabled: true });
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Fire abort to set suppress window
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.error',
|
|
|
- properties: {
|
|
|
- sessionID: 's1',
|
|
|
- error: { name: 'AbortError' },
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- // Disable
|
|
|
- await hook.tool.auto_continue.execute({ enabled: false });
|
|
|
-
|
|
|
- // Reset mock
|
|
|
- ctx.client.session.prompt.mockClear();
|
|
|
-
|
|
|
- // Fire idle again — auto-enable should trigger (4 todos >= 4),
|
|
|
- // resetting counter and suppress window
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Should continue (suppressed window was cleared by auto-enable)
|
|
|
- expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
|
|
|
- });
|
|
|
-
|
|
|
- test('auto-enable counts incomplete todos only, not completed', async () => {
|
|
|
- const ctx = createAutoEnableCtx([
|
|
|
- { id: '1', content: 't1', status: 'completed', priority: 'high' },
|
|
|
- { id: '2', content: 't2', status: 'completed', priority: 'high' },
|
|
|
- { id: '3', content: 't3', status: 'pending', priority: 'high' },
|
|
|
- { id: '4', content: 't4', status: 'pending', priority: 'high' },
|
|
|
- ]);
|
|
|
- const hook = createTodoContinuationHook(ctx, {
|
|
|
- maxContinuations: 5,
|
|
|
- cooldownMs: 50,
|
|
|
- autoEnable: true,
|
|
|
- autoEnableThreshold: 4,
|
|
|
- });
|
|
|
-
|
|
|
- await hook.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.idle',
|
|
|
- properties: { sessionID: 's1' },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- await delay(60);
|
|
|
-
|
|
|
- // Only 2 incomplete todos < threshold 4 → does NOT auto-enable
|
|
|
- expect(ctx.client.session.prompt).not.toHaveBeenCalled();
|
|
|
- });
|
|
|
- });
|
|
|
-});
|