|
|
@@ -819,7 +819,9 @@ describe('task-session-manager hook', () => {
|
|
|
|
|
|
const nextMessages = createMessages('parent-1', 'continue again');
|
|
|
await hook['experimental.chat.messages.transform']({}, nextMessages);
|
|
|
- expect(nextMessages.messages[0].parts[0].text).toBe('continue again');
|
|
|
+ expect(nextMessages.messages[0].parts[0].text).toContain(
|
|
|
+ 'Reusable Sessions',
|
|
|
+ );
|
|
|
});
|
|
|
|
|
|
test('does not reconcile terminal jobs before they are injected into a prompt', async () => {
|
|
|
@@ -884,711 +886,348 @@ describe('task-session-manager hook', () => {
|
|
|
});
|
|
|
});
|
|
|
|
|
|
- test('does not expose running background jobs as resumable sessions', async () => {
|
|
|
- const { hook } = createHook();
|
|
|
-
|
|
|
- await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'background config schema',
|
|
|
- },
|
|
|
- },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- output: [
|
|
|
- 'task_id: child-1 (for polling this task with task_status)',
|
|
|
- 'state: running',
|
|
|
- ].join('\n'),
|
|
|
- },
|
|
|
- );
|
|
|
-
|
|
|
- const next = {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'continue background work',
|
|
|
- task_id: 'exp-1',
|
|
|
- },
|
|
|
- };
|
|
|
- await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-2',
|
|
|
- },
|
|
|
- next,
|
|
|
- );
|
|
|
-
|
|
|
- expect(next.args.task_id).toBeUndefined();
|
|
|
- });
|
|
|
-
|
|
|
- test('drops remembered alias when resumed session is relaunched in background', async () => {
|
|
|
- const { hook } = createHook();
|
|
|
-
|
|
|
- await hook['tool.execute.before'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'config schema',
|
|
|
- },
|
|
|
- },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-1 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
- );
|
|
|
-
|
|
|
- const resumed = {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'continue config schema',
|
|
|
- task_id: 'exp-1',
|
|
|
- },
|
|
|
- };
|
|
|
- await hook['tool.execute.before'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
|
|
|
- resumed,
|
|
|
- );
|
|
|
- expect(resumed.args.task_id).toBe('child-1');
|
|
|
-
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
|
|
|
- {
|
|
|
- output: [
|
|
|
- 'task_id: child-1 (for polling this task with task_status)',
|
|
|
- 'state: running',
|
|
|
- ].join('\n'),
|
|
|
- },
|
|
|
- );
|
|
|
-
|
|
|
- const next = {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'try stale alias',
|
|
|
- task_id: 'exp-1',
|
|
|
- },
|
|
|
- };
|
|
|
- await hook['tool.execute.before'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-3' },
|
|
|
- next,
|
|
|
- );
|
|
|
+ test('completed reconciled job appears reusable and resumes via task', async () => {
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+ const { hook } = createHook({ backgroundJobBoard: board });
|
|
|
|
|
|
- expect(next.args.task_id).toBeUndefined();
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'child-1',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'explorer',
|
|
|
+ description: 'map config schema',
|
|
|
+ });
|
|
|
+ board.updateStatus({
|
|
|
+ taskID: 'child-1',
|
|
|
+ state: 'completed',
|
|
|
+ resultSummary: 'schema mapped',
|
|
|
+ });
|
|
|
|
|
|
- const messages = createMessages('parent-1', 'do something');
|
|
|
+ const messages = createMessages('parent-1', 'continue');
|
|
|
await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
- expect(messages.messages[0].parts[0].text).toContain(
|
|
|
- 'exp-1 / child-1 / explorer / running',
|
|
|
- );
|
|
|
- expect(messages.messages[0].parts[0].text).not.toContain(
|
|
|
- 'explorer: exp-1 config schema',
|
|
|
- );
|
|
|
- });
|
|
|
-
|
|
|
- test('stores task sessions and injects resumable-session block into user message', async () => {
|
|
|
- const { hook } = createHook();
|
|
|
-
|
|
|
- await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'config schema',
|
|
|
- prompt: 'inspect config schema',
|
|
|
- },
|
|
|
+ await hook.event({
|
|
|
+ event: {
|
|
|
+ type: 'session.status',
|
|
|
+ properties: { sessionID: 'parent-1', status: { type: 'idle' } },
|
|
|
},
|
|
|
- );
|
|
|
+ });
|
|
|
|
|
|
- await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-1 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
+ const nextMessages = createMessages('parent-1', 'reuse');
|
|
|
+ await hook['experimental.chat.messages.transform']({}, nextMessages);
|
|
|
+ expect(nextMessages.messages[0].parts[0].text).toContain(
|
|
|
+ '#### Reusable Sessions',
|
|
|
);
|
|
|
-
|
|
|
- const messages = createMessages('parent-1', 'do something');
|
|
|
- await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
-
|
|
|
- const userMessage = messages.messages[0];
|
|
|
- expect(userMessage.parts[0].text).toContain('<resumable_sessions>');
|
|
|
- expect(userMessage.parts[0].text).toContain('### Resumable Sessions');
|
|
|
- expect(userMessage.parts[0].text).toContain(
|
|
|
- 'explorer: exp-1 config schema',
|
|
|
+ expect(nextMessages.messages[0].parts[0].text).toContain(
|
|
|
+ 'exp-1 / child-1 / explorer / completed, reconciled',
|
|
|
);
|
|
|
- expect(userMessage.parts[0].text).toContain('</resumable_sessions>');
|
|
|
- });
|
|
|
-
|
|
|
- test('does not expose a system transform for resumable sessions', async () => {
|
|
|
- const { hook } = createHook();
|
|
|
- expect('experimental.chat.system.transform' in hook).toBe(false);
|
|
|
- });
|
|
|
-
|
|
|
- test('resolves remembered aliases to real task ids before execution', async () => {
|
|
|
- const { hook } = createHook();
|
|
|
-
|
|
|
- await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'config schema',
|
|
|
- prompt: 'inspect config schema',
|
|
|
- },
|
|
|
- },
|
|
|
+ expect(nextMessages.messages[0].parts[0].text).not.toContain(
|
|
|
+ ['<resumable', '_sessions>'].join(''),
|
|
|
);
|
|
|
- await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-1 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
+ expect(nextMessages.messages[0].parts[0].text).not.toContain(
|
|
|
+ ['### Resumable', 'Sessions'].join(' '),
|
|
|
);
|
|
|
|
|
|
- const next = {
|
|
|
+ const resume = {
|
|
|
args: {
|
|
|
subagent_type: 'explorer',
|
|
|
- description: 'continue schema work',
|
|
|
+ description: 'continue config schema',
|
|
|
task_id: 'exp-1',
|
|
|
},
|
|
|
};
|
|
|
await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-2',
|
|
|
- },
|
|
|
- next,
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'resume-1' },
|
|
|
+ resume,
|
|
|
);
|
|
|
-
|
|
|
- expect(next.args.task_id).toBe('child-1');
|
|
|
+ expect(resume.args.task_id).toBe('child-1');
|
|
|
});
|
|
|
|
|
|
- test('tracks files read by child sessions in resumable message context', async () => {
|
|
|
- const { hook } = createHook();
|
|
|
+ test('unreconciled or failed jobs do not resolve as reusable task sessions', async () => {
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+ const { hook } = createHook({ backgroundJobBoard: board });
|
|
|
|
|
|
- await hook.event({
|
|
|
- event: {
|
|
|
- type: 'session.created',
|
|
|
- properties: { info: { id: 'child-1', parentID: 'parent-1' } },
|
|
|
- },
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'done-1',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'oracle',
|
|
|
+ description: 'review plan',
|
|
|
+ });
|
|
|
+ board.updateStatus({ taskID: 'done-1', state: 'completed' });
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'err-1',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'oracle',
|
|
|
+ description: 'bad review',
|
|
|
});
|
|
|
+ board.updateStatus({ taskID: 'err-1', state: 'error' });
|
|
|
+ board.markReconciled('err-1');
|
|
|
|
|
|
- await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'read',
|
|
|
- sessionID: 'child-1',
|
|
|
- callID: 'read-1',
|
|
|
- },
|
|
|
- {
|
|
|
- output: [
|
|
|
- '<path>/tmp/src/index.ts</path>',
|
|
|
- '<type>file</type>',
|
|
|
- '<content>',
|
|
|
- ...Array.from({ length: 12 }, (_, index) => `${index + 1}: line`),
|
|
|
- '</content>',
|
|
|
- ].join('\n'),
|
|
|
- metadata: {
|
|
|
- loaded: ['/tmp/AGENTS.md'],
|
|
|
- },
|
|
|
- },
|
|
|
+ const unreconciled = {
|
|
|
+ args: { subagent_type: 'oracle', task_id: 'ora-1' },
|
|
|
+ };
|
|
|
+ await hook['tool.execute.before'](
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
+ unreconciled,
|
|
|
);
|
|
|
+ expect(unreconciled.args.task_id).toBeUndefined();
|
|
|
|
|
|
+ const failed = { args: { subagent_type: 'oracle', task_id: 'ora-2' } };
|
|
|
await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'session files',
|
|
|
- },
|
|
|
- },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-1 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
|
|
|
+ failed,
|
|
|
);
|
|
|
+ expect(failed.args.task_id).toBeUndefined();
|
|
|
|
|
|
- const messages = createMessages('parent-1', 'do something');
|
|
|
+ const messages = createMessages('parent-1', 'continue');
|
|
|
await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
-
|
|
|
- const userMessage = messages.messages[0];
|
|
|
- expect(userMessage.parts[0].text).toContain('exp-1 session files');
|
|
|
- expect(userMessage.parts[0].text).toContain(
|
|
|
- 'Context read by exp-1: src/index.ts (12 lines)',
|
|
|
+ expect(messages.messages[0].parts[0].text).not.toContain(
|
|
|
+ 'err-1 / oracle / completed, reconciled',
|
|
|
);
|
|
|
});
|
|
|
|
|
|
- test('accumulates multiple reads and hides tiny read context', async () => {
|
|
|
- const { hook } = createHook();
|
|
|
-
|
|
|
- await hook.event({
|
|
|
- event: {
|
|
|
- type: 'session.created',
|
|
|
- properties: { info: { id: 'child-1', parentID: 'parent-1' } },
|
|
|
- },
|
|
|
+ test('running alias is polled through task_status but not resumed by task', async () => {
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+ const { hook } = createHook({ backgroundJobBoard: board });
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'child-1',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'explorer',
|
|
|
+ description: 'map hooks',
|
|
|
});
|
|
|
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'read', sessionID: 'child-1', callID: 'read-1' },
|
|
|
- {
|
|
|
- output: [
|
|
|
- '<path>/tmp/src/small.ts</path>',
|
|
|
- '<content>',
|
|
|
- ...Array.from({ length: 4 }, (_, index) => `${index + 1}: line`),
|
|
|
- '</content>',
|
|
|
- ].join('\n'),
|
|
|
- },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'read', sessionID: 'child-1', callID: 'read-2' },
|
|
|
- {
|
|
|
- output: [
|
|
|
- '<path>/tmp/src/large.ts</path>',
|
|
|
- '<content>',
|
|
|
- ...Array.from({ length: 7 }, (_, index) => `${index + 1}: line`),
|
|
|
- '</content>',
|
|
|
- ].join('\n'),
|
|
|
- },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'read', sessionID: 'child-1', callID: 'read-3' },
|
|
|
- {
|
|
|
- output: [
|
|
|
- '<path>/tmp/src/large.ts</path>',
|
|
|
- '<content>',
|
|
|
- ...Array.from({ length: 5 }, (_, index) => `${index + 8}: line`),
|
|
|
- '</content>',
|
|
|
- ].join('\n'),
|
|
|
- },
|
|
|
- );
|
|
|
-
|
|
|
+ const resume = { args: { subagent_type: 'explorer', task_id: 'exp-1' } };
|
|
|
await hook['tool.execute.before'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
- { args: { subagent_type: 'explorer', description: 'line counts' } },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-1 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
|
|
|
+ resume,
|
|
|
);
|
|
|
+ expect(resume.args.task_id).toBeUndefined();
|
|
|
|
|
|
- const messages = createMessages('parent-1', 'do something');
|
|
|
- await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
-
|
|
|
- const prompt = messages.messages[0].parts[0].text;
|
|
|
- expect(prompt).not.toContain('small.ts');
|
|
|
- expect(prompt).toContain('src/large.ts (12 lines)');
|
|
|
- });
|
|
|
-
|
|
|
- test('counts overlapping repeated reads once per unique line', async () => {
|
|
|
- const { hook } = createHook();
|
|
|
-
|
|
|
- await hook.event({
|
|
|
- event: {
|
|
|
- type: 'session.created',
|
|
|
- properties: { info: { id: 'child-1', parentID: 'parent-1' } },
|
|
|
- },
|
|
|
- });
|
|
|
- for (const call of ['read-1', 'read-2']) {
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'read', sessionID: 'child-1', callID: call },
|
|
|
- {
|
|
|
- output: [
|
|
|
- '<path>/tmp/src/repeat.ts</path>',
|
|
|
- '<content>',
|
|
|
- ...Array.from({ length: 12 }, (_, index) => `${index + 1}: line`),
|
|
|
- '</content>',
|
|
|
- ].join('\n'),
|
|
|
- },
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
+ const poll = { args: { task_id: 'exp-1' } };
|
|
|
await hook['tool.execute.before'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
- { args: { subagent_type: 'explorer', description: 'repeat reads' } },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-1 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
+ { tool: 'task_status', sessionID: 'parent-1', callID: 'poll' },
|
|
|
+ poll,
|
|
|
);
|
|
|
-
|
|
|
- const messages = createMessages('parent-1', 'do something');
|
|
|
- await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
-
|
|
|
- const prompt = messages.messages[0].parts[0].text;
|
|
|
- expect(prompt).toContain('src/repeat.ts (12 lines)');
|
|
|
- expect(prompt).not.toContain('src/repeat.ts (24 lines)');
|
|
|
+ expect(poll.args.task_id).toBe('child-1');
|
|
|
});
|
|
|
|
|
|
- test('uses configured read context thresholds', async () => {
|
|
|
- const { hook } = createHook({
|
|
|
- readContextMinLines: 5,
|
|
|
- readContextMaxFiles: 1,
|
|
|
- });
|
|
|
-
|
|
|
- await hook.event({
|
|
|
- event: {
|
|
|
- type: 'session.created',
|
|
|
- properties: { info: { id: 'child-1', parentID: 'parent-1' } },
|
|
|
- },
|
|
|
+ test('task alias is dropped when subagent_type is missing', async () => {
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+ const { hook } = createHook({ backgroundJobBoard: board });
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'child-1',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'explorer',
|
|
|
+ description: 'map hooks',
|
|
|
});
|
|
|
- for (const [file, lines] of [
|
|
|
- ['small.ts', 4],
|
|
|
- ['medium.ts', 5],
|
|
|
- ['large.ts', 12],
|
|
|
- ] as const) {
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'read', sessionID: 'child-1', callID: `read-${file}` },
|
|
|
- {
|
|
|
- output: [
|
|
|
- `<path>/tmp/src/${file}</path>`,
|
|
|
- '<content>',
|
|
|
- ...Array.from({ length: lines }, (_, line) => `${line + 1}: line`),
|
|
|
- '</content>',
|
|
|
- ].join('\n'),
|
|
|
- },
|
|
|
- );
|
|
|
- }
|
|
|
|
|
|
+ const resume = { args: { task_id: 'exp-1' } };
|
|
|
await hook['tool.execute.before'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
- { args: { subagent_type: 'explorer', description: 'configured caps' } },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-1 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
|
|
|
+ resume,
|
|
|
);
|
|
|
|
|
|
- const messages = createMessages('parent-1', 'do something');
|
|
|
- await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
-
|
|
|
- const prompt = messages.messages[0].parts[0].text;
|
|
|
- expect(prompt).not.toContain('small.ts');
|
|
|
- expect(prompt).toContain('Context read by exp-1:');
|
|
|
- expect(prompt).toContain('(+1 more)');
|
|
|
+ expect(resume.args.task_id).toBeUndefined();
|
|
|
});
|
|
|
|
|
|
- test('ignores reads from unmanaged child sessions', async () => {
|
|
|
- const { hook } = createHook({
|
|
|
- shouldManageSession: (sessionID) => sessionID === 'parent-1',
|
|
|
- });
|
|
|
-
|
|
|
- await hook.event({
|
|
|
- event: {
|
|
|
- type: 'session.created',
|
|
|
- properties: { info: { id: 'child-1', parentID: 'other-parent' } },
|
|
|
- },
|
|
|
+ test('task alias is dropped when subagent_type is invalid', async () => {
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+ const { hook } = createHook({ backgroundJobBoard: board });
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'child-1',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'explorer',
|
|
|
+ description: 'map hooks',
|
|
|
});
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'read', sessionID: 'child-1', callID: 'read-1' },
|
|
|
- {
|
|
|
- output: [
|
|
|
- '<path>/tmp/src/index.ts</path>',
|
|
|
- '<content>',
|
|
|
- ...Array.from({ length: 12 }, (_, index) => `${index + 1}: line`),
|
|
|
- '</content>',
|
|
|
- ].join('\n'),
|
|
|
- },
|
|
|
- );
|
|
|
-
|
|
|
- await hook['tool.execute.before'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
- { args: { subagent_type: 'explorer', description: 'unmanaged read' } },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-1 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
- );
|
|
|
-
|
|
|
- const messages = createMessages('parent-1', 'do something');
|
|
|
- await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
-
|
|
|
- const prompt = messages.messages[0].parts[0].text;
|
|
|
- expect(prompt).toContain('exp-1 unmanaged read');
|
|
|
- expect(prompt).not.toContain('Context read by exp-1');
|
|
|
- });
|
|
|
-
|
|
|
- test('prunes read context when remembered sessions are evicted', async () => {
|
|
|
- const { hook } = createHook();
|
|
|
-
|
|
|
- for (const index of [1, 2, 3]) {
|
|
|
- await hook.event({
|
|
|
- event: {
|
|
|
- type: 'session.created',
|
|
|
- properties: {
|
|
|
- info: { id: `child-${index}`, parentID: 'parent-1' },
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'read', sessionID: `child-${index}`, callID: `read-${index}` },
|
|
|
- {
|
|
|
- output: [
|
|
|
- `<path>/tmp/src/file-${index}.ts</path>`,
|
|
|
- '<content>',
|
|
|
- ...Array.from({ length: 12 }, (_, line) => `${line + 1}: line`),
|
|
|
- '</content>',
|
|
|
- ].join('\n'),
|
|
|
- },
|
|
|
- );
|
|
|
- await hook['tool.execute.before'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: `call-${index}` },
|
|
|
- { args: { subagent_type: 'explorer', description: `thread ${index}` } },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- { tool: 'task', sessionID: 'parent-1', callID: `call-${index}` },
|
|
|
- {
|
|
|
- output: `task_id: child-${index} (for resuming to continue this task if needed)`,
|
|
|
- },
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- const messages = createMessages('parent-1', 'do something');
|
|
|
- await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
-
|
|
|
- const prompt = messages.messages[0].parts[0].text;
|
|
|
- expect(prompt).not.toContain('exp-1 thread 1');
|
|
|
- expect(prompt).not.toContain('file-1.ts');
|
|
|
- expect(prompt).toContain('exp-2 thread 2');
|
|
|
- expect(prompt).toContain('file-2.ts (12 lines)');
|
|
|
- expect(prompt).toContain('exp-3 thread 3');
|
|
|
- expect(prompt).toContain('file-3.ts (12 lines)');
|
|
|
- });
|
|
|
-
|
|
|
- test('drops stale remembered sessions and falls back to fresh', async () => {
|
|
|
- const { hook } = createHook();
|
|
|
-
|
|
|
- await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'config schema',
|
|
|
- },
|
|
|
- },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-1 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
- );
|
|
|
|
|
|
- const next = {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'continue schema work',
|
|
|
- task_id: 'exp-1',
|
|
|
- },
|
|
|
+ const resume = {
|
|
|
+ args: { subagent_type: 'not-an-agent', task_id: 'exp-1' },
|
|
|
};
|
|
|
await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-2',
|
|
|
- },
|
|
|
- next,
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
|
|
|
+ resume,
|
|
|
);
|
|
|
|
|
|
- expect(next.args.task_id).toBe('child-1');
|
|
|
+ expect(resume.args.task_id).toBeUndefined();
|
|
|
+ });
|
|
|
|
|
|
- await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-2',
|
|
|
- },
|
|
|
- {
|
|
|
- output: '[ERROR] Session not found',
|
|
|
- },
|
|
|
+ test('wrong parent or wrong agent alias does not resolve', async () => {
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+ const { hook } = createHook({ backgroundJobBoard: board });
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'child-1',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'explorer',
|
|
|
+ description: 'map hooks',
|
|
|
+ });
|
|
|
+ board.updateStatus({ taskID: 'child-1', state: 'completed' });
|
|
|
+ board.markReconciled('child-1');
|
|
|
+
|
|
|
+ const wrongAgent = { args: { subagent_type: 'oracle', task_id: 'exp-1' } };
|
|
|
+ await hook['tool.execute.before'](
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'agent' },
|
|
|
+ wrongAgent,
|
|
|
);
|
|
|
+ expect(wrongAgent.args.task_id).toBeUndefined();
|
|
|
|
|
|
- const messages = createMessages('parent-1', 'do something');
|
|
|
- await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
- expect(messages.messages[0].parts[0].text).not.toContain('exp-1');
|
|
|
+ const wrongParent = { args: { task_id: 'exp-1' } };
|
|
|
+ await hook['tool.execute.before'](
|
|
|
+ { tool: 'task_status', sessionID: 'parent-2', callID: 'parent' },
|
|
|
+ wrongParent,
|
|
|
+ );
|
|
|
+ expect(wrongParent.args.task_id).toBe('exp-1');
|
|
|
});
|
|
|
|
|
|
- test('drops resumed predecessor when success returns a new task id', async () => {
|
|
|
+ test('unknown raw task_status id remains unchanged', async () => {
|
|
|
const { hook } = createHook();
|
|
|
-
|
|
|
+ const poll = { args: { task_id: 'raw-unknown' } };
|
|
|
await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'config schema',
|
|
|
- },
|
|
|
- },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-1 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
+ { tool: 'task_status', sessionID: 'parent-1', callID: 'poll' },
|
|
|
+ poll,
|
|
|
);
|
|
|
+ expect(poll.args.task_id).toBe('raw-unknown');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('resuming reusable job relaunches running and removes reusable entry', async () => {
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+ const { hook } = createHook({ backgroundJobBoard: board });
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'child-1',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'explorer',
|
|
|
+ description: 'map hooks',
|
|
|
+ });
|
|
|
+ board.updateStatus({ taskID: 'child-1', state: 'completed' });
|
|
|
+ board.markReconciled('child-1');
|
|
|
|
|
|
+ const resume = { args: { subagent_type: 'explorer', task_id: 'exp-1' } };
|
|
|
await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-2',
|
|
|
- },
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'continue schema work',
|
|
|
- task_id: 'exp-1',
|
|
|
- },
|
|
|
- },
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
|
|
|
+ resume,
|
|
|
);
|
|
|
await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-2',
|
|
|
- },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-2 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
|
|
|
+ { output: ['task_id: child-1', 'state: running'].join('\n') },
|
|
|
);
|
|
|
|
|
|
- const messages = createMessages('parent-1', 'do something');
|
|
|
+ const messages = createMessages('parent-1', 'continue');
|
|
|
await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
-
|
|
|
- const prompt = messages.messages[0].parts[0].text;
|
|
|
- expect(prompt).toContain('continue schema work');
|
|
|
- expect(prompt).not.toContain('config schema');
|
|
|
+ expect(messages.messages[0].parts[0].text).toContain(
|
|
|
+ 'exp-1 / child-1 / explorer / running',
|
|
|
+ );
|
|
|
+ expect(messages.messages[0].parts[0].text).toContain(
|
|
|
+ '#### Reusable Sessions\n- none',
|
|
|
+ );
|
|
|
});
|
|
|
|
|
|
- test('does not drop remembered session on non-runtime session text', async () => {
|
|
|
+ test('bare task id output without state does not create reusable job', async () => {
|
|
|
const { hook } = createHook();
|
|
|
-
|
|
|
await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'config schema',
|
|
|
- },
|
|
|
- },
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
+ { args: { subagent_type: 'explorer', description: 'legacy output' } },
|
|
|
);
|
|
|
await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-1 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
+ { output: 'task_id: child-1 (for resuming to continue this task)' },
|
|
|
);
|
|
|
|
|
|
- await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-2',
|
|
|
+ const messages = createMessages('parent-1', 'continue');
|
|
|
+ await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
+ expect(messages.messages[0].parts[0].text).toBe('continue');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('reads before and after launch attach with unique-line counts and caps', async () => {
|
|
|
+ const { hook } = createHook({
|
|
|
+ readContextMinLines: 5,
|
|
|
+ readContextMaxFiles: 1,
|
|
|
+ });
|
|
|
+ await hook.event({
|
|
|
+ event: {
|
|
|
+ type: 'session.created',
|
|
|
+ properties: { info: { id: 'child-1', parentID: 'parent-1' } },
|
|
|
},
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'continue schema work',
|
|
|
- task_id: 'exp-1',
|
|
|
+ });
|
|
|
+ for (const [file, start, count] of [
|
|
|
+ ['small.ts', 1, 4],
|
|
|
+ ['large.ts', 1, 12],
|
|
|
+ ['large.ts', 7, 6],
|
|
|
+ ['medium.ts', 1, 5],
|
|
|
+ ] as const) {
|
|
|
+ await hook['tool.execute.after'](
|
|
|
+ { tool: 'read', sessionID: 'child-1', callID: `read-${file}-${start}` },
|
|
|
+ {
|
|
|
+ output: [
|
|
|
+ `<path>/tmp/src/${file}</path>`,
|
|
|
+ '<content>',
|
|
|
+ ...Array.from(
|
|
|
+ { length: count },
|
|
|
+ (_, index) => `${start + index}: line`,
|
|
|
+ ),
|
|
|
+ '</content>',
|
|
|
+ ].join('\n'),
|
|
|
},
|
|
|
- },
|
|
|
+ );
|
|
|
+ }
|
|
|
+ await hook['tool.execute.before'](
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
+ { args: { subagent_type: 'explorer', description: 'context caps' } },
|
|
|
);
|
|
|
await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-2',
|
|
|
- },
|
|
|
- {
|
|
|
- output: 'Found no session cookies in fixtures, continuing analysis.',
|
|
|
- },
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
+ { output: ['task_id: child-1', 'state: running'].join('\n') },
|
|
|
);
|
|
|
-
|
|
|
- const messages = createMessages('parent-1', 'do something');
|
|
|
+ await hook['tool.execute.after'](
|
|
|
+ { tool: 'task_status', sessionID: 'parent-1', callID: 'status-1' },
|
|
|
+ { output: ['task_id: child-1', 'state: completed'].join('\n') },
|
|
|
+ );
|
|
|
+ const messages = createMessages('parent-1', 'continue');
|
|
|
await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
+ await hook.event({
|
|
|
+ event: {
|
|
|
+ type: 'session.status',
|
|
|
+ properties: { sessionID: 'parent-1', status: { type: 'idle' } },
|
|
|
+ },
|
|
|
+ });
|
|
|
+ const next = createMessages('parent-1', 'reuse');
|
|
|
+ await hook['experimental.chat.messages.transform']({}, next);
|
|
|
+ const prompt = next.messages[0].parts[0].text;
|
|
|
+ expect(prompt).not.toContain('small.ts');
|
|
|
+ expect(prompt).toContain('src/large.ts (12 lines)');
|
|
|
+ expect(prompt).not.toContain('src/large.ts (18 lines)');
|
|
|
+ expect(prompt).toContain('(+1 more)');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('reusable cap evicts only old reusable jobs, not active jobs', async () => {
|
|
|
+ const board = new BackgroundJobBoard({ maxReusablePerAgent: 2 });
|
|
|
+ for (const index of [1, 2, 3]) {
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: `done-${index}`,
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'explorer',
|
|
|
+ description: `done ${index}`,
|
|
|
+ now: index,
|
|
|
+ });
|
|
|
+ board.updateStatus({
|
|
|
+ taskID: `done-${index}`,
|
|
|
+ state: 'completed',
|
|
|
+ now: index,
|
|
|
+ });
|
|
|
+ board.markReconciled(`done-${index}`, index);
|
|
|
+ }
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'running-1',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'explorer',
|
|
|
+ description: 'active',
|
|
|
+ now: 4,
|
|
|
+ });
|
|
|
|
|
|
- expect(messages.messages[0].parts[0].text).toContain('exp-1 config schema');
|
|
|
+ expect(board.get('done-1')).toBeUndefined();
|
|
|
+ expect(board.get('done-2')).toBeDefined();
|
|
|
+ expect(board.get('done-3')).toBeDefined();
|
|
|
+ expect(board.get('running-1')).toBeDefined();
|
|
|
+ });
|
|
|
+
|
|
|
+ test('does not expose a system transform for resumable sessions', async () => {
|
|
|
+ const { hook } = createHook();
|
|
|
+ expect('experimental.chat.system.transform' in hook).toBe(false);
|
|
|
});
|
|
|
|
|
|
test('ignores sessions that are not orchestrator-managed', async () => {
|
|
|
@@ -1626,7 +1265,7 @@ describe('task-session-manager hook', () => {
|
|
|
expect(messages.messages[0].parts[0].text).toBe('do something');
|
|
|
});
|
|
|
|
|
|
- test('cleans up remembered sessions when parent or child is deleted', async () => {
|
|
|
+ test('cleans up background jobs when parent or child is deleted', async () => {
|
|
|
const { hook } = createHook();
|
|
|
|
|
|
await hook['tool.execute.before'](
|
|
|
@@ -1710,89 +1349,28 @@ describe('task-session-manager hook', () => {
|
|
|
expect(messages.messages[0].parts[0].text).toBe('do something');
|
|
|
});
|
|
|
|
|
|
- test('deduplicates pending call order when a resume call is recorded twice', async () => {
|
|
|
- const { hook } = createHook();
|
|
|
-
|
|
|
- await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'config schema',
|
|
|
- },
|
|
|
- },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-1',
|
|
|
- },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-1 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
- );
|
|
|
-
|
|
|
+ test('parent deletion clears jobs and pending calls', async () => {
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+ const { hook } = createHook({ backgroundJobBoard: board });
|
|
|
await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-2',
|
|
|
- },
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'explorer',
|
|
|
- description: 'continue schema work',
|
|
|
- task_id: 'exp-1',
|
|
|
- },
|
|
|
- },
|
|
|
- );
|
|
|
- await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-2',
|
|
|
- },
|
|
|
- {
|
|
|
- output: '[ERROR] Session not found',
|
|
|
- },
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
+ { args: { subagent_type: 'oracle', description: 'architecture review' } },
|
|
|
);
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'child-1',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'oracle',
|
|
|
+ description: 'architecture review',
|
|
|
+ });
|
|
|
|
|
|
- await hook['tool.execute.before'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-3',
|
|
|
- },
|
|
|
- {
|
|
|
- args: {
|
|
|
- subagent_type: 'oracle',
|
|
|
- description: 'architecture review',
|
|
|
- },
|
|
|
- },
|
|
|
- );
|
|
|
+ await hook.event({
|
|
|
+ event: { type: 'session.deleted', properties: { sessionID: 'parent-1' } },
|
|
|
+ });
|
|
|
await hook['tool.execute.after'](
|
|
|
- {
|
|
|
- tool: 'task',
|
|
|
- sessionID: 'parent-1',
|
|
|
- callID: 'call-3',
|
|
|
- },
|
|
|
- {
|
|
|
- output:
|
|
|
- 'task_id: child-3 (for resuming to continue this task if needed)',
|
|
|
- },
|
|
|
+ { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
+ { output: ['task_id: child-2', 'state: running'].join('\n') },
|
|
|
);
|
|
|
|
|
|
- const messages = createMessages('parent-1', 'do something');
|
|
|
- await hook['experimental.chat.messages.transform']({}, messages);
|
|
|
-
|
|
|
- expect(messages.messages[0].parts[0].text).toContain(
|
|
|
- 'oracle: ora-1 architecture review',
|
|
|
- );
|
|
|
+ expect(board.list('parent-1')).toHaveLength(0);
|
|
|
});
|
|
|
});
|