|
@@ -12,14 +12,11 @@ import {
|
|
|
PHASE_REMINDER_METADATA_KEY,
|
|
PHASE_REMINDER_METADATA_KEY,
|
|
|
} from '../phase-reminder';
|
|
} from '../phase-reminder';
|
|
|
import { createPostFileToolNudgeHook } from '../post-file-tool-nudge';
|
|
import { createPostFileToolNudgeHook } from '../post-file-tool-nudge';
|
|
|
-import {
|
|
|
|
|
- hasConsumedContinuationAttempt,
|
|
|
|
|
- resetContinuationAttemptGateForTests,
|
|
|
|
|
-} from './continuation-attempt-gate';
|
|
|
|
|
import {
|
|
import {
|
|
|
BACKGROUND_JOB_BOARD_METADATA_KEY,
|
|
BACKGROUND_JOB_BOARD_METADATA_KEY,
|
|
|
createTaskSessionManagerHook,
|
|
createTaskSessionManagerHook,
|
|
|
} from './index';
|
|
} from './index';
|
|
|
|
|
+import { resetUserWaitGateForTests } from './user-wait-gate';
|
|
|
|
|
|
|
|
// Route getClient back to _ctx.client so existing _ctx.client.session
|
|
// Route getClient back to _ctx.client so existing _ctx.client.session
|
|
|
// mocks continue to work through the new v2 lookup path.
|
|
// mocks continue to work through the new v2 lookup path.
|
|
@@ -87,7 +84,6 @@ type HookOptions = {
|
|
|
readContextMaxFiles?: number;
|
|
readContextMaxFiles?: number;
|
|
|
strategy?: 'latest' | 'checkpoint-compatible';
|
|
strategy?: 'latest' | 'checkpoint-compatible';
|
|
|
maxRetainedSnapshots?: number;
|
|
maxRetainedSnapshots?: number;
|
|
|
- continueOnIdle?: boolean;
|
|
|
|
|
backgroundJobBoard?: BackgroundJobBoard;
|
|
backgroundJobBoard?: BackgroundJobBoard;
|
|
|
sessionStatus?: unknown;
|
|
sessionStatus?: unknown;
|
|
|
sessionClient?: Record<string, unknown>;
|
|
sessionClient?: Record<string, unknown>;
|
|
@@ -117,7 +113,6 @@ function createHook(options?: HookOptions) {
|
|
|
strategy: options?.strategy,
|
|
strategy: options?.strategy,
|
|
|
readContextMinLines: options?.readContextMinLines,
|
|
readContextMinLines: options?.readContextMinLines,
|
|
|
readContextMaxFiles: options?.readContextMaxFiles,
|
|
readContextMaxFiles: options?.readContextMaxFiles,
|
|
|
- continueOnIdle: options?.continueOnIdle ?? false,
|
|
|
|
|
backgroundJobBoard: options?.backgroundJobBoard,
|
|
backgroundJobBoard: options?.backgroundJobBoard,
|
|
|
backgroundJobSupervisor: options?.backgroundJobSupervisor,
|
|
backgroundJobSupervisor: options?.backgroundJobSupervisor,
|
|
|
shouldManageSession: options?.shouldManageSession ?? (() => true),
|
|
shouldManageSession: options?.shouldManageSession ?? (() => true),
|
|
@@ -132,62 +127,6 @@ function createHook(options?: HookOptions) {
|
|
|
return { hook };
|
|
return { hook };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function createContinuationHook(options?: HookOptions) {
|
|
|
|
|
- return createHook({
|
|
|
|
|
- ...options,
|
|
|
|
|
- continueOnIdle: options?.continueOnIdle ?? true,
|
|
|
|
|
- });
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function createContinuationSessionClient(
|
|
|
|
|
- promptAsync: unknown,
|
|
|
|
|
- overrides?: Record<string, unknown>,
|
|
|
|
|
-): Record<string, unknown> {
|
|
|
|
|
- return {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'in_progress' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- ...overrides,
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function createRuntimeUserTurn(options: {
|
|
|
|
|
- sessionID?: string;
|
|
|
|
|
- messageID: string;
|
|
|
|
|
- providerID: string;
|
|
|
|
|
- modelID: string;
|
|
|
|
|
- variant?: string;
|
|
|
|
|
-}) {
|
|
|
|
|
- const sessionID = options.sessionID ?? 'parent-1';
|
|
|
|
|
- const model = {
|
|
|
|
|
- providerID: options.providerID,
|
|
|
|
|
- modelID: options.modelID,
|
|
|
|
|
- };
|
|
|
|
|
- const parts = [{ type: 'text', text: 'continue with this model' }];
|
|
|
|
|
- return {
|
|
|
|
|
- input: {
|
|
|
|
|
- sessionID,
|
|
|
|
|
- messageID: options.messageID,
|
|
|
|
|
- model,
|
|
|
|
|
- ...(options.variant ? { variant: options.variant } : {}),
|
|
|
|
|
- parts,
|
|
|
|
|
- },
|
|
|
|
|
- output: {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: options.messageID,
|
|
|
|
|
- sessionID,
|
|
|
|
|
- role: 'user' as const,
|
|
|
|
|
- model: {
|
|
|
|
|
- ...model,
|
|
|
|
|
- ...(options.variant ? { variant: options.variant } : {}),
|
|
|
|
|
- },
|
|
|
|
|
- },
|
|
|
|
|
- parts,
|
|
|
|
|
- },
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
function createMessages(sessionID: string, text = 'user message') {
|
|
function createMessages(sessionID: string, text = 'user message') {
|
|
|
return {
|
|
return {
|
|
|
messages: [
|
|
messages: [
|
|
@@ -271,7 +210,7 @@ function setupCompletedJob(
|
|
|
describe('task-session-manager hook', () => {
|
|
describe('task-session-manager hook', () => {
|
|
|
beforeEach(() => {
|
|
beforeEach(() => {
|
|
|
// Process-global gate only — never reset inside createHook/production paths.
|
|
// Process-global gate only — never reset inside createHook/production paths.
|
|
|
- resetContinuationAttemptGateForTests();
|
|
|
|
|
|
|
+ resetUserWaitGateForTests();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
test('ignores messages without OpenCode info or parts', async () => {
|
|
test('ignores messages without OpenCode info or parts', async () => {
|
|
@@ -4752,80 +4691,77 @@ describe('task-session-manager hook', () => {
|
|
|
test.each([
|
|
test.each([
|
|
|
['foreground-created-first', ['foreground-child', 'background-child']],
|
|
['foreground-created-first', ['foreground-child', 'background-child']],
|
|
|
['background-created-first', ['background-child', 'foreground-child']],
|
|
['background-created-first', ['background-child', 'foreground-child']],
|
|
|
- ])(
|
|
|
|
|
- 'ambiguous early created events never supervise the foreground child (%s)',
|
|
|
|
|
- async (_, createdOrder) => {
|
|
|
|
|
- const board = new BackgroundJobBoard();
|
|
|
|
|
- const clock = createSupervisorClock();
|
|
|
|
|
- const abort = mock(async () => undefined);
|
|
|
|
|
- const supervisor = new BackgroundJobSupervisor({
|
|
|
|
|
- backgroundJobStore: board,
|
|
|
|
|
- wallClockTimeoutMs: 100,
|
|
|
|
|
- abortGraceMs: 10,
|
|
|
|
|
- abort,
|
|
|
|
|
- now: clock.now,
|
|
|
|
|
- setTimeout: clock.setTimeout,
|
|
|
|
|
- clearTimeout: clock.clearTimeout,
|
|
|
|
|
- });
|
|
|
|
|
- const { hook } = createHook({
|
|
|
|
|
- backgroundJobBoard: board,
|
|
|
|
|
- backgroundJobSupervisor: supervisor,
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ ])('ambiguous early created events never supervise the foreground child (%s)', async (_, createdOrder) => {
|
|
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
|
|
+ const clock = createSupervisorClock();
|
|
|
|
|
+ const abort = mock(async () => undefined);
|
|
|
|
|
+ const supervisor = new BackgroundJobSupervisor({
|
|
|
|
|
+ backgroundJobStore: board,
|
|
|
|
|
+ wallClockTimeoutMs: 100,
|
|
|
|
|
+ abortGraceMs: 10,
|
|
|
|
|
+ abort,
|
|
|
|
|
+ now: clock.now,
|
|
|
|
|
+ setTimeout: clock.setTimeout,
|
|
|
|
|
+ clearTimeout: clock.clearTimeout,
|
|
|
|
|
+ });
|
|
|
|
|
+ const { hook } = createHook({
|
|
|
|
|
+ backgroundJobBoard: board,
|
|
|
|
|
+ backgroundJobSupervisor: supervisor,
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- await hook['tool.execute.before'](
|
|
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
|
|
|
|
|
- {
|
|
|
|
|
- args: {
|
|
|
|
|
- subagent_type: 'explorer',
|
|
|
|
|
- background: true,
|
|
|
|
|
- description: 'background child',
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ await hook['tool.execute.before'](
|
|
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
|
|
|
|
|
+ {
|
|
|
|
|
+ args: {
|
|
|
|
|
+ subagent_type: 'explorer',
|
|
|
|
|
+ background: true,
|
|
|
|
|
+ description: 'background child',
|
|
|
},
|
|
},
|
|
|
- );
|
|
|
|
|
- await hook['tool.execute.before'](
|
|
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
|
|
|
|
|
- {
|
|
|
|
|
- args: {
|
|
|
|
|
- subagent_type: 'explorer',
|
|
|
|
|
- background: false,
|
|
|
|
|
- description: 'foreground child',
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ },
|
|
|
|
|
+ );
|
|
|
|
|
+ await hook['tool.execute.before'](
|
|
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
|
|
|
|
|
+ {
|
|
|
|
|
+ args: {
|
|
|
|
|
+ subagent_type: 'explorer',
|
|
|
|
|
+ background: false,
|
|
|
|
|
+ description: 'foreground child',
|
|
|
},
|
|
},
|
|
|
- );
|
|
|
|
|
|
|
+ },
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- for (const taskID of createdOrder) {
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'session.created',
|
|
|
|
|
- properties: { info: { id: taskID, parentID: 'parent-1' } },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ for (const taskID of createdOrder) {
|
|
|
|
|
+ await hook.event({
|
|
|
|
|
+ event: {
|
|
|
|
|
+ type: 'session.created',
|
|
|
|
|
+ properties: { info: { id: taskID, parentID: 'parent-1' } },
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- expect(board.get('background-child')?.background).toBe(false);
|
|
|
|
|
- expect(board.get('foreground-child')?.background).toBe(false);
|
|
|
|
|
- expect(abort).not.toHaveBeenCalled();
|
|
|
|
|
|
|
+ expect(board.get('background-child')?.background).toBe(false);
|
|
|
|
|
+ expect(board.get('foreground-child')?.background).toBe(false);
|
|
|
|
|
+ expect(abort).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
- await hook['tool.execute.after'](
|
|
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
|
|
|
|
|
- { output: taskLaunchOutput('foreground-child') },
|
|
|
|
|
- );
|
|
|
|
|
- await hook['tool.execute.after'](
|
|
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
|
|
|
|
|
- { output: taskLaunchOutput('background-child') },
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ await hook['tool.execute.after'](
|
|
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
|
|
|
|
|
+ { output: taskLaunchOutput('foreground-child') },
|
|
|
|
|
+ );
|
|
|
|
|
+ await hook['tool.execute.after'](
|
|
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
|
|
|
|
|
+ { output: taskLaunchOutput('background-child') },
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- expect(board.get('foreground-child')?.background).toBe(false);
|
|
|
|
|
- expect(board.get('background-child')?.background).toBe(true);
|
|
|
|
|
- const backgroundJob = board.get('background-child');
|
|
|
|
|
- expect(backgroundJob).toBeDefined();
|
|
|
|
|
- const deadline = (backgroundJob?.runStartedAt ?? 0) + 100;
|
|
|
|
|
- await clock.advanceTo(deadline);
|
|
|
|
|
|
|
+ expect(board.get('foreground-child')?.background).toBe(false);
|
|
|
|
|
+ expect(board.get('background-child')?.background).toBe(true);
|
|
|
|
|
+ const backgroundJob = board.get('background-child');
|
|
|
|
|
+ expect(backgroundJob).toBeDefined();
|
|
|
|
|
+ const deadline = (backgroundJob?.runStartedAt ?? 0) + 100;
|
|
|
|
|
+ await clock.advanceTo(deadline);
|
|
|
|
|
|
|
|
- expect(abort).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(abort).toHaveBeenCalledWith('background-child');
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ expect(abort).toHaveBeenCalledTimes(1);
|
|
|
|
|
+ expect(abort).toHaveBeenCalledWith('background-child');
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
test('missing after-hook callID fails closed while an exact background call remains', async () => {
|
|
test('missing after-hook callID fails closed while an exact background call remains', async () => {
|
|
|
const board = new BackgroundJobBoard();
|
|
const board = new BackgroundJobBoard();
|
|
@@ -5224,40 +5160,7 @@ describe('task-session-manager hook', () => {
|
|
|
).toHaveLength(1);
|
|
).toHaveLength(1);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- test('defaults continueOnIdle off: continuation SDK calls do not run', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'in_progress' }] }));
|
|
|
|
|
- const hook = createTaskSessionManagerHook(
|
|
|
|
|
- {
|
|
|
|
|
- client: {
|
|
|
|
|
- session: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- },
|
|
|
|
|
- directory: '/tmp',
|
|
|
|
|
- worktree: '/tmp',
|
|
|
|
|
- } as never,
|
|
|
|
|
- {
|
|
|
|
|
- maxSessionsPerAgent: 2,
|
|
|
|
|
- maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- shouldManageSession: () => true,
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('explicit continueOnIdle false reconciles parent terminal job without continuation', async () => {
|
|
|
|
|
|
|
+ test('idle reconciliation still runs without orchestrator wake SDK calls', async () => {
|
|
|
const board = new BackgroundJobBoard();
|
|
const board = new BackgroundJobBoard();
|
|
|
board.registerLaunch({
|
|
board.registerLaunch({
|
|
|
taskID: 'child-1',
|
|
taskID: 'child-1',
|
|
@@ -5273,7 +5176,6 @@ describe('task-session-manager hook', () => {
|
|
|
const promptAsync = mock(async () => ({}));
|
|
const promptAsync = mock(async () => ({}));
|
|
|
const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
const { hook } = createHook({
|
|
const { hook } = createHook({
|
|
|
- continueOnIdle: false,
|
|
|
|
|
backgroundJobBoard: board,
|
|
backgroundJobBoard: board,
|
|
|
idleReconcileDelayMs: 0,
|
|
idleReconcileDelayMs: 0,
|
|
|
sessionClient: {
|
|
sessionClient: {
|
|
@@ -5303,2365 +5205,81 @@ describe('task-session-manager hook', () => {
|
|
|
expect(promptAsync).not.toHaveBeenCalled();
|
|
expect(promptAsync).not.toHaveBeenCalled();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- test('continues after reconciling an injected parent terminal job', async () => {
|
|
|
|
|
- const board = new BackgroundJobBoard();
|
|
|
|
|
- setupCompletedJob(board);
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- backgroundJobBoard: board,
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.injectBackgroundJobBoard({}, createMessages('parent-1'));
|
|
|
|
|
- expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(board.get('child-1')).toMatchObject({
|
|
|
|
|
- state: 'reconciled',
|
|
|
|
|
- terminalUnreconciled: false,
|
|
|
|
|
- });
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledWith(
|
|
|
|
|
- expect.objectContaining({
|
|
|
|
|
- body: expect.objectContaining({
|
|
|
|
|
- parts: [expect.objectContaining({ synthetic: true })],
|
|
|
|
|
- }),
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('nudges once for incomplete todos when parent and children are inactive', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- continueOnIdle: true,
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'in_progress' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledWith(
|
|
|
|
|
- expect.objectContaining({
|
|
|
|
|
- path: { id: 'parent-1' },
|
|
|
|
|
- body: expect.objectContaining({
|
|
|
|
|
- agent: 'orchestrator',
|
|
|
|
|
- parts: [expect.objectContaining({ synthetic: true })],
|
|
|
|
|
- }),
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('preserves the current session model and variant on continuation nudges', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const get = mock(async () => ({
|
|
|
|
|
- data: {
|
|
|
|
|
- model: {
|
|
|
|
|
- providerID: 'runtime-provider',
|
|
|
|
|
- id: 'selected-model',
|
|
|
|
|
- variant: 'selected-variant',
|
|
|
|
|
- },
|
|
|
|
|
- },
|
|
|
|
|
- }));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: createContinuationSessionClient(promptAsync, {
|
|
|
|
|
- get,
|
|
|
|
|
- }),
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(get).toHaveBeenCalledWith({
|
|
|
|
|
- path: { id: 'parent-1' },
|
|
|
|
|
- throwOnError: true,
|
|
|
|
|
- });
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledWith(
|
|
|
|
|
- expect.objectContaining({
|
|
|
|
|
- body: expect.objectContaining({
|
|
|
|
|
- model: {
|
|
|
|
|
- providerID: 'runtime-provider',
|
|
|
|
|
- modelID: 'selected-model',
|
|
|
|
|
- },
|
|
|
|
|
- variant: 'selected-variant',
|
|
|
|
|
- }),
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('falls back to the latest external user model when session lookup fails', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const userTurn = createRuntimeUserTurn({
|
|
|
|
|
- messageID: 'user-1',
|
|
|
|
|
- providerID: 'runtime-provider',
|
|
|
|
|
- modelID: 'selected-model',
|
|
|
|
|
- variant: 'selected-variant',
|
|
|
|
|
- });
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: createContinuationSessionClient(promptAsync, {
|
|
|
|
|
- get: mock(async () => {
|
|
|
|
|
- throw new Error('session lookup unavailable');
|
|
|
|
|
- }),
|
|
|
|
|
- }),
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ test('hasInputWait is true after wait_for_user and clears on distinct external message', async () => {
|
|
|
|
|
+ const { hook } = createHook();
|
|
|
|
|
+ hook.beginUserWait('parent-1');
|
|
|
|
|
+ expect(hook.hasInputWait('parent-1')).toBe(true);
|
|
|
|
|
|
|
|
hook.observeChatMessage(
|
|
hook.observeChatMessage(
|
|
|
|
|
+ { sessionID: 'parent-1', messageID: 'msg-user-resumes' },
|
|
|
{
|
|
{
|
|
|
- sessionID: userTurn.input.sessionID,
|
|
|
|
|
- messageID: userTurn.input.messageID,
|
|
|
|
|
- parts: userTurn.input.parts,
|
|
|
|
|
|
|
+ message: {
|
|
|
|
|
+ id: 'msg-user-resumes',
|
|
|
|
|
+ role: 'user',
|
|
|
|
|
+ sessionID: 'parent-1',
|
|
|
|
|
+ },
|
|
|
|
|
+ parts: [{ type: 'text', text: 'The manual step is complete.' }],
|
|
|
},
|
|
},
|
|
|
- userTurn.output,
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledWith(
|
|
|
|
|
- expect.objectContaining({
|
|
|
|
|
- body: expect.objectContaining({
|
|
|
|
|
- model: {
|
|
|
|
|
- providerID: 'runtime-provider',
|
|
|
|
|
- modelID: 'selected-model',
|
|
|
|
|
- },
|
|
|
|
|
- variant: 'selected-variant',
|
|
|
|
|
- }),
|
|
|
|
|
- }),
|
|
|
|
|
);
|
|
);
|
|
|
|
|
+ expect(hook.hasInputWait('parent-1')).toBe(false);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- test('treats a current session model without variant as authoritative', async () => {
|
|
|
|
|
- const promptAsync = mock(async (_input: unknown) => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: createContinuationSessionClient(promptAsync, {
|
|
|
|
|
- get: mock(async () => ({
|
|
|
|
|
- data: {
|
|
|
|
|
- model: {
|
|
|
|
|
- providerID: 'current-provider',
|
|
|
|
|
- id: 'current-model',
|
|
|
|
|
- },
|
|
|
|
|
- },
|
|
|
|
|
- })),
|
|
|
|
|
- }),
|
|
|
|
|
- });
|
|
|
|
|
- const previousTurn = createRuntimeUserTurn({
|
|
|
|
|
- messageID: 'user-1',
|
|
|
|
|
- providerID: 'previous-provider',
|
|
|
|
|
- modelID: 'previous-model',
|
|
|
|
|
- variant: 'previous-variant',
|
|
|
|
|
- });
|
|
|
|
|
- hook.observeChatMessage(previousTurn.input, previousTurn.output);
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- const request = promptAsync.mock.calls[0]?.[0] as {
|
|
|
|
|
- body?: {
|
|
|
|
|
- model?: Record<string, unknown>;
|
|
|
|
|
- variant?: string;
|
|
|
|
|
- };
|
|
|
|
|
- };
|
|
|
|
|
- expect(request?.body?.model).toEqual({
|
|
|
|
|
- providerID: 'current-provider',
|
|
|
|
|
- modelID: 'current-model',
|
|
|
|
|
- });
|
|
|
|
|
- expect(request?.body).not.toHaveProperty('variant');
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('only external messages replace the model fallback and clear its variant', async () => {
|
|
|
|
|
- const promptAsync = mock(async (_input: unknown) => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: createContinuationSessionClient(promptAsync),
|
|
|
|
|
- });
|
|
|
|
|
- const selectedTurn = createRuntimeUserTurn({
|
|
|
|
|
- messageID: 'user-1',
|
|
|
|
|
- providerID: 'selected-provider',
|
|
|
|
|
- modelID: 'selected-model',
|
|
|
|
|
- variant: 'selected-variant',
|
|
|
|
|
- });
|
|
|
|
|
- hook.observeChatMessage(selectedTurn.input, selectedTurn.output);
|
|
|
|
|
- const newTurn = createRuntimeUserTurn({
|
|
|
|
|
- messageID: 'user-2',
|
|
|
|
|
- providerID: 'new-provider',
|
|
|
|
|
- modelID: 'new-model',
|
|
|
|
|
- });
|
|
|
|
|
- hook.observeChatMessage(newTurn.input, newTurn.output);
|
|
|
|
|
|
|
+ test('synthetic and internal messages do not clear wait_for_user', async () => {
|
|
|
|
|
+ const { hook } = createHook();
|
|
|
|
|
+ hook.beginUserWait('parent-1');
|
|
|
hook.observeChatMessage(
|
|
hook.observeChatMessage(
|
|
|
- {
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- messageID: 'synthetic-1',
|
|
|
|
|
- model: { providerID: 'static-provider', modelID: 'static-model' },
|
|
|
|
|
- variant: 'static-variant',
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ { sessionID: 'parent-1', messageID: 'msg-internal' },
|
|
|
{
|
|
{
|
|
|
message: {
|
|
message: {
|
|
|
- id: 'synthetic-1',
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
|
|
+ id: 'msg-internal',
|
|
|
role: 'user',
|
|
role: 'user',
|
|
|
|
|
+ sessionID: 'parent-1',
|
|
|
},
|
|
},
|
|
|
parts: [
|
|
parts: [
|
|
|
- {
|
|
|
|
|
- type: 'text',
|
|
|
|
|
- text: 'synthetic continuation',
|
|
|
|
|
- synthetic: true,
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ { type: 'text', synthetic: true, text: 'synthetic continuation' },
|
|
|
|
|
+ createInternalAgentTextPart('internal continuation'),
|
|
|
],
|
|
],
|
|
|
},
|
|
},
|
|
|
);
|
|
);
|
|
|
|
|
+ expect(hook.hasInputWait('parent-1')).toBe(true);
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
|
|
+ test('question/permission asks arm hasInputWait until resolved', async () => {
|
|
|
|
|
+ const { hook } = createHook();
|
|
|
await hook.event({
|
|
await hook.event({
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
|
|
+ event: {
|
|
|
|
|
+ type: 'question.asked',
|
|
|
|
|
+ properties: { sessionID: 'parent-1', id: 'q-1' },
|
|
|
|
|
+ },
|
|
|
});
|
|
});
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- const request = promptAsync.mock.calls[0]?.[0] as {
|
|
|
|
|
- body?: {
|
|
|
|
|
- model?: Record<string, unknown>;
|
|
|
|
|
- variant?: string;
|
|
|
|
|
- };
|
|
|
|
|
- };
|
|
|
|
|
- expect(request?.body?.model).toEqual({
|
|
|
|
|
- providerID: 'new-provider',
|
|
|
|
|
- modelID: 'new-model',
|
|
|
|
|
|
|
+ expect(hook.hasInputWait('parent-1')).toBe(true);
|
|
|
|
|
+ await hook.event({
|
|
|
|
|
+ event: {
|
|
|
|
|
+ type: 'question.replied',
|
|
|
|
|
+ properties: { sessionID: 'parent-1', requestID: 'q-1' },
|
|
|
|
|
+ },
|
|
|
});
|
|
});
|
|
|
- expect(request?.body).not.toHaveProperty('variant');
|
|
|
|
|
|
|
+ expect(hook.hasInputWait('parent-1')).toBe(false);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- test('a user message invalidates continuation while current model lookup is pending', async () => {
|
|
|
|
|
- let resolveGet!: (value: {
|
|
|
|
|
- data: {
|
|
|
|
|
- model: { providerID: string; id: string; variant: string };
|
|
|
|
|
- };
|
|
|
|
|
- }) => void;
|
|
|
|
|
- const get = mock(
|
|
|
|
|
- () =>
|
|
|
|
|
- new Promise<{
|
|
|
|
|
- data: {
|
|
|
|
|
- model: { providerID: string; id: string; variant: string };
|
|
|
|
|
- };
|
|
|
|
|
- }>((resolve) => {
|
|
|
|
|
- resolveGet = resolve;
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: createContinuationSessionClient(promptAsync, {
|
|
|
|
|
- get,
|
|
|
|
|
- }),
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ test('user waits survive hook disposal and clear on genuine deletion', async () => {
|
|
|
|
|
+ const owner = createHook().hook;
|
|
|
|
|
+ owner.beginUserWait('parent-1');
|
|
|
|
|
+ expect(owner.hasInputWait('parent-1')).toBe(true);
|
|
|
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
|
|
+ await owner.event({
|
|
|
|
|
+ event: { type: 'server.instance.disposed' },
|
|
|
});
|
|
});
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(get).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- const newTurn = createRuntimeUserTurn({
|
|
|
|
|
- messageID: 'user-2',
|
|
|
|
|
- providerID: 'new-provider',
|
|
|
|
|
- modelID: 'new-model',
|
|
|
|
|
- });
|
|
|
|
|
- hook.observeChatMessage(newTurn.input, newTurn.output);
|
|
|
|
|
- resolveGet({
|
|
|
|
|
- data: {
|
|
|
|
|
- model: {
|
|
|
|
|
- providerID: 'stale-provider',
|
|
|
|
|
- id: 'stale-model',
|
|
|
|
|
- variant: 'stale-variant',
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ // Process-local wait survives disposal of one hook instance.
|
|
|
|
|
+ const next = createHook().hook;
|
|
|
|
|
+ expect(next.hasInputWait('parent-1')).toBe(true);
|
|
|
|
|
+
|
|
|
|
|
+ await next.event({
|
|
|
|
|
+ event: {
|
|
|
|
|
+ type: 'session.deleted',
|
|
|
|
|
+ properties: { sessionID: 'parent-1' },
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('paired idle events submit at most one continuation', async () => {
|
|
|
|
|
- const board = new BackgroundJobBoard();
|
|
|
|
|
- setupCompletedJob(board);
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- backgroundJobBoard: board,
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.injectBackgroundJobBoard({}, createMessages('parent-1'));
|
|
|
|
|
-
|
|
|
|
|
- await Promise.all([
|
|
|
|
|
- hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- }),
|
|
|
|
|
- hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'session.status',
|
|
|
|
|
- properties: { sessionID: 'parent-1', status: { type: 'idle' } },
|
|
|
|
|
- },
|
|
|
|
|
- }),
|
|
|
|
|
- ]);
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(board.get('child-1')?.terminalUnreconciled).toBe(false);
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('only one of two hook instances enters deferred pre-read SDK calls', async () => {
|
|
|
|
|
- let releaseTodo!: () => void;
|
|
|
|
|
- const todo = mock(
|
|
|
|
|
- () =>
|
|
|
|
|
- new Promise<{ data: Array<{ status: string }> }>((resolve) => {
|
|
|
|
|
- releaseTodo = () => resolve({ data: [{ status: 'pending' }] });
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
- const children = mock(async () => ({ data: [] }));
|
|
|
|
|
- const status = mock(async () => ({ data: {} }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const sessionClient = { todo, children, status, promptAsync };
|
|
|
|
|
- const makeHook = () =>
|
|
|
|
|
- createTaskSessionManagerHook(
|
|
|
|
|
- {
|
|
|
|
|
- client: { session: sessionClient },
|
|
|
|
|
- directory: '/tmp',
|
|
|
|
|
- worktree: '/tmp',
|
|
|
|
|
- } as never,
|
|
|
|
|
- {
|
|
|
|
|
- maxSessionsPerAgent: 2,
|
|
|
|
|
- maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
|
|
|
|
|
- continueOnIdle: true,
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- shouldManageSession: () => true,
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- const hookA = makeHook();
|
|
|
|
|
- const hookB = makeHook();
|
|
|
|
|
-
|
|
|
|
|
- await Promise.all([
|
|
|
|
|
- hookA.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- }),
|
|
|
|
|
- hookB.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- }),
|
|
|
|
|
- ]);
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- // Reservation is taken before any SDK liveness read; loser never enters.
|
|
|
|
|
- expect(todo).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(children).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(status).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- releaseTodo();
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('non-owner disposal cannot rearm a committed continuation epoch', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const sessionClient = {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- };
|
|
|
|
|
- const makeHook = () =>
|
|
|
|
|
- createTaskSessionManagerHook(
|
|
|
|
|
- {
|
|
|
|
|
- client: { session: sessionClient },
|
|
|
|
|
- directory: '/tmp',
|
|
|
|
|
- worktree: '/tmp',
|
|
|
|
|
- } as never,
|
|
|
|
|
- {
|
|
|
|
|
- maxSessionsPerAgent: 2,
|
|
|
|
|
- maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
|
|
|
|
|
- continueOnIdle: true,
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- shouldManageSession: () => true,
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- const owner = makeHook();
|
|
|
|
|
- const other = makeHook();
|
|
|
|
|
-
|
|
|
|
|
- await owner.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(hasConsumedContinuationAttempt('parent-1')).toBe(true);
|
|
|
|
|
-
|
|
|
|
|
- // Disposing a different hook instance must not clear process-global consumed.
|
|
|
|
|
- await other.event({ event: { type: 'server.instance.disposed' } });
|
|
|
|
|
- await other.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(hasConsumedContinuationAttempt('parent-1')).toBe(true);
|
|
|
|
|
-
|
|
|
|
|
- // Owner disposal after commit also leaves consumed intact.
|
|
|
|
|
- await owner.event({ event: { type: 'server.instance.disposed' } });
|
|
|
|
|
- const replacement = makeHook();
|
|
|
|
|
- await replacement.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('committed non-settling promptAsync is not retried even through disposal', async () => {
|
|
|
|
|
- let resolvePrompt!: (value: unknown) => void;
|
|
|
|
|
- const promptAsync = mock(
|
|
|
|
|
- () =>
|
|
|
|
|
- new Promise((resolve) => {
|
|
|
|
|
- resolvePrompt = resolve;
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(hasConsumedContinuationAttempt('parent-1')).toBe(true);
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({ event: { type: 'server.instance.disposed' } });
|
|
|
|
|
- const { hook: nextHook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await nextHook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- resolvePrompt({});
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('rejected promptAsync is not retried in the same epoch', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => {
|
|
|
|
|
- throw new Error('prompt rejected');
|
|
|
|
|
- });
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(hasConsumedContinuationAttempt('parent-1')).toBe(true);
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('pending read invalidated then valid idle can try again', async () => {
|
|
|
|
|
- // First SDK read stays permanently unresolved — release must not depend on
|
|
|
|
|
- // finally after the hung promise settles (old finally-only design fails).
|
|
|
|
|
- let todoCalls = 0;
|
|
|
|
|
- const todo = mock(
|
|
|
|
|
- () =>
|
|
|
|
|
- new Promise<{ data: Array<{ status: string }> }>((resolve) => {
|
|
|
|
|
- todoCalls++;
|
|
|
|
|
- if (todoCalls === 1) {
|
|
|
|
|
- // Intentionally never resolve the first read.
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- resolve({ data: [{ status: 'pending' }] });
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(todo).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- // Invalidate while SDK read is still pending — releases uncommitted reservation.
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1', id: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- expect(hasConsumedContinuationAttempt('parent-1')).toBe(false);
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.replied',
|
|
|
|
|
- properties: { sessionID: 'parent-1', requestID: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(todo).toHaveBeenCalledTimes(2);
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('owner disposal while read pending lets another hook attempt', async () => {
|
|
|
|
|
- let todoCalls = 0;
|
|
|
|
|
- const todo = mock(
|
|
|
|
|
- () =>
|
|
|
|
|
- new Promise<{ data: Array<{ status: string }> }>((resolve) => {
|
|
|
|
|
- todoCalls++;
|
|
|
|
|
- if (todoCalls === 1) {
|
|
|
|
|
- // Permanently unresolved — disposal must release without settle.
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- resolve({ data: [{ status: 'pending' }] });
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const sessionClient = {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- };
|
|
|
|
|
- const makeHook = () =>
|
|
|
|
|
- createTaskSessionManagerHook(
|
|
|
|
|
- {
|
|
|
|
|
- client: { session: sessionClient },
|
|
|
|
|
- directory: '/tmp',
|
|
|
|
|
- worktree: '/tmp',
|
|
|
|
|
- } as never,
|
|
|
|
|
- {
|
|
|
|
|
- maxSessionsPerAgent: 2,
|
|
|
|
|
- maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
|
|
|
|
|
- continueOnIdle: true,
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- shouldManageSession: () => true,
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- const owner = makeHook();
|
|
|
|
|
- const other = makeHook();
|
|
|
|
|
-
|
|
|
|
|
- await owner.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(todo).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- await owner.event({ event: { type: 'server.instance.disposed' } });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(hasConsumedContinuationAttempt('parent-1')).toBe(false);
|
|
|
|
|
-
|
|
|
|
|
- await other.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(todo).toHaveBeenCalledTimes(2);
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('fallback session deletion after commit does not resubmit', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const sessionClient = {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- };
|
|
|
|
|
- let fallbackInProgress = false;
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- isFallbackInProgress: () => fallbackInProgress,
|
|
|
|
|
- sessionClient,
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(hasConsumedContinuationAttempt('parent-1')).toBe(true);
|
|
|
|
|
-
|
|
|
|
|
- fallbackInProgress = true;
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'session.deleted',
|
|
|
|
|
- properties: { sessionID: 'parent-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- expect(hasConsumedContinuationAttempt('parent-1')).toBe(true);
|
|
|
|
|
-
|
|
|
|
|
- fallbackInProgress = false;
|
|
|
|
|
- // After fallback teardown/recreation, idle must not rearm without a real user message.
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('malformed SDK data releases reservation so a later valid attempt can run', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- let todoCalls = 0;
|
|
|
|
|
- const todo = mock(async () => {
|
|
|
|
|
- todoCalls++;
|
|
|
|
|
- if (todoCalls === 1) return { data: undefined };
|
|
|
|
|
- return { data: [{ status: 'pending' }] };
|
|
|
|
|
- });
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('active parent releases reservation so a later idle can continue', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- let statusCalls = 0;
|
|
|
|
|
- const status = mock(async () => {
|
|
|
|
|
- statusCalls++;
|
|
|
|
|
- // First evaluation sees busy on the initial status read and returns.
|
|
|
|
|
- if (statusCalls === 1) {
|
|
|
|
|
- return { data: { 'parent-1': { type: 'busy' } } };
|
|
|
|
|
- }
|
|
|
|
|
- return { data: {} };
|
|
|
|
|
- });
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status,
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('does not evaluate or nudge while a question or permission waits', async () => {
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1', id: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'permission.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1', id: 'permission-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('does not evaluate or nudge after wait_for_user requests text-only HITL', async () => {
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- hook.beginUserWait('parent-1');
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('a distinct external user message releases wait_for_user', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- hook.beginUserWait('parent-1');
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1', messageID: 'msg-user-resumes' },
|
|
|
|
|
- {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-user-resumes',
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [{ type: 'text', text: 'The manual step is complete.' }],
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('a duplicate external message cannot clear a newer user wait', async () => {
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- const previousUserMessage = {
|
|
|
|
|
- input: { sessionID: 'parent-1', messageID: 'msg-before-wait' },
|
|
|
|
|
- output: {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-before-wait',
|
|
|
|
|
- role: 'user' as const,
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [{ type: 'text', text: 'Start the long task.' }],
|
|
|
|
|
- },
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- previousUserMessage.input,
|
|
|
|
|
- previousUserMessage.output,
|
|
|
|
|
- );
|
|
|
|
|
- hook.beginUserWait('parent-1');
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- previousUserMessage.input,
|
|
|
|
|
- previousUserMessage.output,
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('synthetic and internal messages do not clear wait_for_user', async () => {
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- hook.beginUserWait('parent-1');
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1', messageID: 'msg-internal' },
|
|
|
|
|
- {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-internal',
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [
|
|
|
|
|
- { type: 'text', synthetic: true, text: 'synthetic continuation' },
|
|
|
|
|
- createInternalAgentTextPart('internal continuation'),
|
|
|
|
|
- ],
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('a foreground-fallback replay marker does not clear wait_for_user', async () => {
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- hook.beginUserWait('parent-1');
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1', messageID: 'msg-fallback-replay' },
|
|
|
|
|
- {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-fallback-replay',
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [
|
|
|
|
|
- { type: 'text', text: 'Start the long task.' },
|
|
|
|
|
- createInternalAgentTextPart('foreground fallback replay'),
|
|
|
|
|
- ],
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('wait_for_user cancels a scheduled continuation before SDK reads', async () => {
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const children = mock(async () => ({ data: [] }));
|
|
|
|
|
- const status = mock(async () => ({ data: {} }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: { todo, children, status, promptAsync },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- hook.beginUserWait('parent-1');
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(children).not.toHaveBeenCalled();
|
|
|
|
|
- expect(status).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('wait_for_user invalidates an in-flight continuation evaluation', async () => {
|
|
|
|
|
- let resolveTodo!: (value: { data: { status: string }[] }) => void;
|
|
|
|
|
- const todo = mock(
|
|
|
|
|
- () =>
|
|
|
|
|
- new Promise<{ data: { status: string }[] }>((resolveTodoRequest) => {
|
|
|
|
|
- resolveTodo = resolveTodoRequest;
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(todo).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- hook.beginUserWait('parent-1');
|
|
|
|
|
- resolveTodo({ data: [{ status: 'pending' }] });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('a user wait from another hook revokes an in-flight shared reservation', async () => {
|
|
|
|
|
- let resolveTodo!: (value: { data: { status: string }[] }) => void;
|
|
|
|
|
- const todo = mock(
|
|
|
|
|
- () =>
|
|
|
|
|
- new Promise<{ data: { status: string }[] }>((resolveTodoRequest) => {
|
|
|
|
|
- resolveTodo = resolveTodoRequest;
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const sessionClient = {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- };
|
|
|
|
|
- const owner = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient,
|
|
|
|
|
- }).hook;
|
|
|
|
|
- const waiter = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient,
|
|
|
|
|
- }).hook;
|
|
|
|
|
-
|
|
|
|
|
- await owner.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(todo).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- waiter.beginUserWait('parent-1');
|
|
|
|
|
- resolveTodo({ data: [{ status: 'pending' }] });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('external user input clears only the explicit wait while a question remains', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- hook.beginUserWait('parent-1');
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1', id: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1', messageID: 'msg-user-replied' },
|
|
|
|
|
- {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-user-replied',
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [{ type: 'text', text: 'Manual work is done.' }],
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.replied',
|
|
|
|
|
- properties: { sessionID: 'parent-1', requestID: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('user waits survive hook disposal and clear on genuine deletion', async () => {
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const sessionClient = {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- };
|
|
|
|
|
- const makeHook = () =>
|
|
|
|
|
- createContinuationHook({ idleReconcileDelayMs: 0, sessionClient }).hook;
|
|
|
|
|
- const owner = makeHook();
|
|
|
|
|
-
|
|
|
|
|
- owner.beginUserWait('parent-1');
|
|
|
|
|
- await owner.event({ event: { type: 'server.instance.disposed' } });
|
|
|
|
|
- const replacement = makeHook();
|
|
|
|
|
- await replacement.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- await replacement.event({
|
|
|
|
|
- event: { type: 'session.deleted', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- const afterDeletion = makeHook();
|
|
|
|
|
- await afterDeletion.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('fallback session deletion preserves wait_for_user', async () => {
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- let fallbackInProgress = true;
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- isFallbackInProgress: () => fallbackInProgress,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- hook.beginUserWait('parent-1');
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.deleted', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- fallbackInProgress = false;
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('cancels a scheduled continuation when an input wait arrives before its timer fires', async () => {
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const children = mock(async () => ({ data: [] }));
|
|
|
|
|
- const status = mock(async () => ({ data: {} }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: { todo, children, status, promptAsync },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'permission.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1', id: 'permission-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(children).not.toHaveBeenCalled();
|
|
|
|
|
- expect(status).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('fails closed when an id-less ask races a scheduled continuation', async () => {
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.replied',
|
|
|
|
|
- properties: { sessionID: 'parent-1', requestID: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('clears only the resolved input wait and resumes on a later idle', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1', id: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'permission.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1', id: 'permission-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1', id: 'question-2' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.replied',
|
|
|
|
|
- properties: { sessionID: 'parent-1', requestID: 'unknown-question' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.replied',
|
|
|
|
|
- properties: { sessionID: 'parent-1', requestID: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'permission.replied',
|
|
|
|
|
- properties: { sessionID: 'parent-1', requestID: 'permission-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.rejected',
|
|
|
|
|
- properties: { sessionID: 'parent-1', requestID: 'question-2' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('resumes on a later idle after a question rejection', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1', id: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.rejected',
|
|
|
|
|
- properties: { sessionID: 'parent-1', requestID: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('invalidates an in-flight continuation when an input wait arrives', async () => {
|
|
|
|
|
- let resolveTodo!: (value: { data: { status: string }[] }) => void;
|
|
|
|
|
- const todo = mock(
|
|
|
|
|
- () =>
|
|
|
|
|
- new Promise<{ data: { status: string }[] }>((resolve) => {
|
|
|
|
|
- resolveTodo = resolve;
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(todo).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1', id: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- resolveTodo({ data: [{ status: 'pending' }] });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('internal and synthetic messages do not clear an input wait', async () => {
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1', id: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1', messageID: 'msg-synthetic-wait' },
|
|
|
|
|
- {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-synthetic-wait',
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [
|
|
|
|
|
- { type: 'text', synthetic: true, text: 'synthetic response' },
|
|
|
|
|
- createInternalAgentTextPart('internal response'),
|
|
|
|
|
- ],
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('retains input waits across a session error', async () => {
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.asked',
|
|
|
|
|
- properties: { sessionID: 'parent-1', id: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.error', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('clears stale input waits on session and server cleanup', async () => {
|
|
|
|
|
- // Distinct session IDs: disposed must not clear process-global consumed from
|
|
|
|
|
- // a prior deleted+idle iteration on the same id.
|
|
|
|
|
- for (const { sessionID, lifecycleEvent } of [
|
|
|
|
|
- {
|
|
|
|
|
- sessionID: 'parent-deleted',
|
|
|
|
|
- lifecycleEvent: {
|
|
|
|
|
- type: 'session.deleted',
|
|
|
|
|
- properties: { sessionID: 'parent-deleted' },
|
|
|
|
|
- },
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- sessionID: 'parent-disposed',
|
|
|
|
|
- lifecycleEvent: { type: 'server.instance.disposed' },
|
|
|
|
|
- },
|
|
|
|
|
- ] as const) {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'question.asked',
|
|
|
|
|
- properties: { sessionID, id: 'question-1' },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({ event: lifecycleEvent });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('coalesces paired idle events and suppresses active children', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const children = mock(async () => ({ data: [{ id: 'child-1' }] }));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children,
|
|
|
|
|
- status: mock(async () => ({ data: { 'child-1': { type: 'busy' } } })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'session.status',
|
|
|
|
|
- properties: { sessionID: 'parent-1', status: { type: 'idle' } },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(children).toHaveBeenCalledTimes(1);
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('runtime-shaped external messages rearm a consumed nudge', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1', messageID: 'msg-continue-1' },
|
|
|
|
|
- {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-continue-1',
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [{ type: 'text', text: 'continue' }],
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(2);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('output.message.id rearms when input.messageID is missing', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1' },
|
|
|
|
|
- {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-output-id-only',
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [{ type: 'text', text: 'continue' }],
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(2);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('ID-less output.message object identity rearms once', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- const message = { role: 'user', sessionID: 'parent-1' };
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1' },
|
|
|
|
|
- { message, parts: [{ type: 'text', text: 'continue' }] },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(2);
|
|
|
|
|
-
|
|
|
|
|
- // Same object again must not open another epoch.
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1' },
|
|
|
|
|
- { message, parts: [{ type: 'text', text: 'continue' }] },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(2);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('two hooks share ID-less output.message object identity', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const sessionClient = {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- };
|
|
|
|
|
- const makeHook = () =>
|
|
|
|
|
- createTaskSessionManagerHook(
|
|
|
|
|
- {
|
|
|
|
|
- client: { session: sessionClient },
|
|
|
|
|
- directory: '/tmp',
|
|
|
|
|
- worktree: '/tmp',
|
|
|
|
|
- } as never,
|
|
|
|
|
- {
|
|
|
|
|
- maxSessionsPerAgent: 2,
|
|
|
|
|
- continueOnIdle: true,
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- shouldManageSession: () => true,
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- const hookA = makeHook();
|
|
|
|
|
- const hookB = makeHook();
|
|
|
|
|
- const message = { role: 'user', sessionID: 'parent-1' };
|
|
|
|
|
- const output = {
|
|
|
|
|
- message,
|
|
|
|
|
- parts: [{ type: 'text', text: 'continue' }],
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- await hookA.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- hookA.observeChatMessage({ sessionID: 'parent-1' }, output);
|
|
|
|
|
- await hookA.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(2);
|
|
|
|
|
-
|
|
|
|
|
- hookB.observeChatMessage({ sessionID: 'parent-1' }, output);
|
|
|
|
|
- await hookB.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(2);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('distinct ID-less message objects each open a new epoch', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- const text = 'identical text must not dedupe distinct objects';
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1' },
|
|
|
|
|
- {
|
|
|
|
|
- message: { role: 'user', sessionID: 'parent-1' },
|
|
|
|
|
- parts: [{ type: 'text', text }],
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(2);
|
|
|
|
|
-
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1' },
|
|
|
|
|
- {
|
|
|
|
|
- message: { role: 'user', sessionID: 'parent-1' },
|
|
|
|
|
- parts: [{ type: 'text', text }],
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(3);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('missing id and output.message fails closed without rearm', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- // sessionID only on input; no messageID and no output.message object.
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- {
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- parts: [{ type: 'text', text: 'continue' }],
|
|
|
|
|
- },
|
|
|
|
|
- { parts: [{ type: 'text', text: 'continue' }] },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('same user message observed by two hooks rearms only one new epoch', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const sessionClient = {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- };
|
|
|
|
|
- const makeHook = () =>
|
|
|
|
|
- createTaskSessionManagerHook(
|
|
|
|
|
- {
|
|
|
|
|
- client: { session: sessionClient },
|
|
|
|
|
- directory: '/tmp',
|
|
|
|
|
- worktree: '/tmp',
|
|
|
|
|
- } as never,
|
|
|
|
|
- {
|
|
|
|
|
- maxSessionsPerAgent: 2,
|
|
|
|
|
- maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
|
|
|
|
|
- continueOnIdle: true,
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- shouldManageSession: () => true,
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- const hookA = makeHook();
|
|
|
|
|
- const hookB = makeHook();
|
|
|
|
|
- const userMessage = {
|
|
|
|
|
- input: { sessionID: 'parent-1', messageID: 'msg-shared-1' },
|
|
|
|
|
- output: {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-shared-1',
|
|
|
|
|
- role: 'user' as const,
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [{ type: 'text', text: 'continue' }],
|
|
|
|
|
- },
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- // Initial epoch dispatch.
|
|
|
|
|
- await hookA.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- // Interleave: A observes → new-epoch idle on A → B observes same message.
|
|
|
|
|
- hookA.observeChatMessage(userMessage.input, userMessage.output);
|
|
|
|
|
- await hookA.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(2);
|
|
|
|
|
-
|
|
|
|
|
- hookB.observeChatMessage(userMessage.input, userMessage.output);
|
|
|
|
|
- await hookB.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- // Same message must not open a third epoch.
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(2);
|
|
|
|
|
-
|
|
|
|
|
- await hookA.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(2);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('shared observe always cancels each hook local pre-message idle timer', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
|
|
|
|
|
- const children = mock(async () => ({ data: [] }));
|
|
|
|
|
- const status = mock(async () => ({ data: {} }));
|
|
|
|
|
- const sessionClient = { todo, children, status, promptAsync };
|
|
|
|
|
- const makeHook = () =>
|
|
|
|
|
- createTaskSessionManagerHook(
|
|
|
|
|
- {
|
|
|
|
|
- client: { session: sessionClient },
|
|
|
|
|
- directory: '/tmp',
|
|
|
|
|
- worktree: '/tmp',
|
|
|
|
|
- } as never,
|
|
|
|
|
- {
|
|
|
|
|
- maxSessionsPerAgent: 2,
|
|
|
|
|
- maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
|
|
|
|
|
- continueOnIdle: true,
|
|
|
|
|
- // Non-zero so B can hold a pending timer across the observe.
|
|
|
|
|
- idleReconcileDelayMs: 40,
|
|
|
|
|
- shouldManageSession: () => true,
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- const hookA = makeHook();
|
|
|
|
|
- const hookB = makeHook();
|
|
|
|
|
- const userMessage = {
|
|
|
|
|
- input: { sessionID: 'parent-1', messageID: 'msg-shared-timer' },
|
|
|
|
|
- output: {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-shared-timer',
|
|
|
|
|
- role: 'user' as const,
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [{ type: 'text', text: 'continue' }],
|
|
|
|
|
- },
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- // B arms a pre-message idle timer (must not fire after shared observe).
|
|
|
|
|
- await hookB.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- hookA.observeChatMessage(userMessage.input, userMessage.output);
|
|
|
|
|
- // Global rearm already recorded; B must still invalidate local timer/token.
|
|
|
|
|
- hookB.observeChatMessage(userMessage.input, userMessage.output);
|
|
|
|
|
-
|
|
|
|
|
- await new Promise((resolve) => setTimeout(resolve, 80));
|
|
|
|
|
- expect(todo).not.toHaveBeenCalled();
|
|
|
|
|
- expect(children).not.toHaveBeenCalled();
|
|
|
|
|
- expect(status).not.toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- // Only a fresh post-message idle may enter SDK / promptAsync.
|
|
|
|
|
- await hookA.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await new Promise((resolve) => setTimeout(resolve, 80));
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(todo).toHaveBeenCalled();
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('file-only external messages rearm a consumed nudge', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1', messageID: 'msg-file-1' },
|
|
|
|
|
- {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-file-1',
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [{ type: 'file', filename: 'command-output.txt' }],
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(2);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('synthetic completion messages do not rearm a consumed nudge', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1', messageID: 'msg-synthetic-1' },
|
|
|
|
|
- {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-synthetic-1',
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [
|
|
|
|
|
- {
|
|
|
|
|
- type: 'text',
|
|
|
|
|
- synthetic: true,
|
|
|
|
|
- text: 'Background task completed: child-1',
|
|
|
|
|
- },
|
|
|
|
|
- ],
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('nudge busy-to-idle cycle does not send a second unchanged nudge', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'session.status',
|
|
|
|
|
- properties: { sessionID: 'parent-1', status: { type: 'busy' } },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('retry status invalidates a pending continuation evaluation', async () => {
|
|
|
|
|
- let resolveTodo!: (value: { data: { status: string }[] }) => void;
|
|
|
|
|
- const todo = mock(
|
|
|
|
|
- () =>
|
|
|
|
|
- new Promise<{ data: { status: string }[] }>((resolve) => {
|
|
|
|
|
- resolveTodo = resolve;
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo,
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- expect(todo).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: {
|
|
|
|
|
- type: 'session.status',
|
|
|
|
|
- properties: { sessionID: 'parent-1', status: { type: 'retry' } },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- resolveTodo({ data: [{ status: 'pending' }] });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('terminal-unreconciled jobs suppress continuation nudges', async () => {
|
|
|
|
|
- const board = new BackgroundJobBoard();
|
|
|
|
|
- setupCompletedJob(board);
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- backgroundJobBoard: board,
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook['experimental.chat.messages.transform'](
|
|
|
|
|
- {},
|
|
|
|
|
- createMessages('parent-1'),
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('missing SDK response data fails closed without nudging', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: undefined })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('does not nudge when todos are completed or cancelled only', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({
|
|
|
|
|
- data: [{ status: 'completed' }, { status: 'cancelled' }],
|
|
|
|
|
- })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('does not nudge while the parent is active', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: { 'parent-1': { type: 'busy' } } })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('does not nudge while a child is retrying', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [{ id: 'child-1' }] })),
|
|
|
|
|
- status: mock(async () => ({
|
|
|
|
|
- data: { 'child-1': { type: 'retrying' } },
|
|
|
|
|
- })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('does not rearm a consumed nudge for its actual internal part', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- hook.observeChatMessage(
|
|
|
|
|
- { sessionID: 'parent-1', messageID: 'msg-internal-nudge' },
|
|
|
|
|
- {
|
|
|
|
|
- message: {
|
|
|
|
|
- id: 'msg-internal-nudge',
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- sessionID: 'parent-1',
|
|
|
|
|
- },
|
|
|
|
|
- parts: [createInternalAgentTextPart('Continue coordinating')],
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('keeps a rejected prompt consumed', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => {
|
|
|
|
|
- throw new Error('prompt failed');
|
|
|
|
|
- });
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('keeps a failed prompt response consumed', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({ error: 'prompt failed' }));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('fails closed for missing or throwing SDK endpoints', async () => {
|
|
|
|
|
- const missingPrompt = mock(async () => ({}));
|
|
|
|
|
- const { hook: missingHook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: { promptAsync: missingPrompt },
|
|
|
|
|
- });
|
|
|
|
|
- const throwingPrompt = mock(async () => ({}));
|
|
|
|
|
- const { hook: throwingHook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => {
|
|
|
|
|
- throw new Error('todo unavailable');
|
|
|
|
|
- }),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync: throwingPrompt,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- for (const hook of [missingHook, throwingHook]) {
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(missingPrompt).not.toHaveBeenCalled();
|
|
|
|
|
- expect(throwingPrompt).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('does not nudge when fallback is already in progress', async () => {
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- isFallbackInProgress: () => true,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('does not nudge when fallback starts during evaluation', async () => {
|
|
|
|
|
- let fallbackInProgress = false;
|
|
|
|
|
- let releaseTodos: (() => void) | undefined;
|
|
|
|
|
- const todos = new Promise<{ data: Array<{ status: string }> }>(
|
|
|
|
|
- (resolve) => {
|
|
|
|
|
- releaseTodos = () => resolve({ data: [{ status: 'pending' }] });
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- isFallbackInProgress: () => fallbackInProgress,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => todos),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- fallbackInProgress = true;
|
|
|
|
|
- releaseTodos?.();
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('final gate blocks a terminal result that arrives during SDK queries', async () => {
|
|
|
|
|
- const board = new BackgroundJobBoard();
|
|
|
|
|
- let childrenCalls = 0;
|
|
|
|
|
- let releaseLatestChildren: (() => void) | undefined;
|
|
|
|
|
- const latestChildren = new Promise<{ data: Array<unknown> }>((resolve) => {
|
|
|
|
|
- releaseLatestChildren = () => resolve({ data: [] });
|
|
|
|
|
- });
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- backgroundJobBoard: board,
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => ({ data: [{ status: 'pending' }] })),
|
|
|
|
|
- children: mock(async () => {
|
|
|
|
|
- childrenCalls++;
|
|
|
|
|
- return childrenCalls === 1 ? { data: [] } : latestChildren;
|
|
|
|
|
- }),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- setupCompletedJob(board);
|
|
|
|
|
- releaseLatestChildren?.();
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- test('instance disposal invalidates an evaluation whose timer already fired', async () => {
|
|
|
|
|
- let releaseTodos: (() => void) | undefined;
|
|
|
|
|
- const todos = new Promise<{ data: Array<{ status: string }> }>(
|
|
|
|
|
- (resolve) => {
|
|
|
|
|
- releaseTodos = () => resolve({ data: [{ status: 'pending' }] });
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- const promptAsync = mock(async () => ({}));
|
|
|
|
|
- const { hook } = createContinuationHook({
|
|
|
|
|
- idleReconcileDelayMs: 0,
|
|
|
|
|
- sessionClient: {
|
|
|
|
|
- todo: mock(async () => todos),
|
|
|
|
|
- children: mock(async () => ({ data: [] })),
|
|
|
|
|
- status: mock(async () => ({ data: {} })),
|
|
|
|
|
- promptAsync,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- await hook.event({
|
|
|
|
|
- event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
|
|
|
|
|
- });
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
- await hook.event({ event: { type: 'server.instance.disposed' } });
|
|
|
|
|
- releaseTodos?.();
|
|
|
|
|
- await flushContinuation();
|
|
|
|
|
-
|
|
|
|
|
- expect(promptAsync).not.toHaveBeenCalled();
|
|
|
|
|
|
|
+ expect(next.hasInputWait('parent-1')).toBe(false);
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|