Skip to content

Commit 77cdefe

Browse files
authored
Merge pull request #1049 from HeZ2z/fix/task-result-running-status
2 parents 32294d4 + c2bd99c commit 77cdefe

2 files changed

Lines changed: 148 additions & 50 deletions

File tree

src/tools/task-result.test.ts

Lines changed: 96 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, mock, test } from 'bun:test';
22
import { BackgroundJobBoard } from '../utils/background-job-board';
33
import { buildPluginInput } from '../v2/client-shim';
44
import { createTaskResultTool } from './task-result';
5+
import { createTaskStatusTool } from './task-status';
56

67
let mockClient: Record<string, any>;
78

@@ -26,11 +27,22 @@ function createTool() {
2627
const status = mock(async () => ({ data: {} }));
2728
mockClient = { session: { get, messages, status } };
2829

30+
const input = { directory: '/test/project' } as any;
2931
const tools = createTaskResultTool({
30-
input: { directory: '/test/project' } as any,
32+
input,
3133
backgroundJobBoard: board,
3234
});
33-
return { board, get, messages, tool: tools.task_result };
35+
const statusTools = createTaskStatusTool({
36+
input,
37+
backgroundJobBoard: board,
38+
});
39+
return {
40+
board,
41+
get,
42+
messages,
43+
statusTool: statusTools.task_status,
44+
tool: tools.task_result,
45+
};
3446
}
3547

3648
describe('task_result', () => {
@@ -85,21 +97,56 @@ describe('task_result', () => {
8597
).resolves.toBe('final findings');
8698
});
8799

88-
test('rejects a still-running tracked task', async () => {
89-
const { board, tool, messages } = createTool();
100+
test('returns a non-error status for a still-running tracked task', async () => {
101+
const { board, tool, statusTool, messages } = createTool();
90102
board.registerLaunch({
91103
taskID: 'ses_child1',
92104
parentSessionID: 'parent-1',
93105
agent: 'explorer',
94106
description: 'trace bug',
95107
});
96108

109+
const output = await tool.execute({ task_id: 'exp-1' }, {
110+
sessionID: 'parent-1',
111+
agent: 'orchestrator',
112+
} as any);
113+
114+
expect(output).toBe(
115+
[
116+
'task_id: ses_child1',
117+
'state: running',
118+
'message: Task is still running. Wait for its terminal result.',
119+
'next: use task_status to inspect the task',
120+
].join('\n'),
121+
);
97122
await expect(
98-
tool.execute({ task_id: 'exp-1' }, {
123+
statusTool.execute({ task_id: 'exp-1' }, {
99124
sessionID: 'parent-1',
100125
agent: 'orchestrator',
101126
} as any),
102-
).rejects.toThrow('still running');
127+
).resolves.toContain('state: running');
128+
expect(messages).not.toHaveBeenCalled();
129+
});
130+
131+
test('preserves a live retry state for a tracked running task', async () => {
132+
const { board, tool, messages } = createTool();
133+
board.registerLaunch({
134+
taskID: 'ses_child1',
135+
parentSessionID: 'parent-1',
136+
agent: 'explorer',
137+
description: 'trace bug',
138+
});
139+
mockClient.session.status.mockResolvedValue({
140+
data: { ses_child1: { type: 'retry' } },
141+
});
142+
143+
const output = await tool.execute({ task_id: 'exp-1' }, {
144+
sessionID: 'parent-1',
145+
agent: 'orchestrator',
146+
} as any);
147+
148+
expect(output).toContain('state: retry');
149+
expect(output).toContain('next: use task_status to inspect the task');
103150
expect(messages).not.toHaveBeenCalled();
104151
});
105152

@@ -122,13 +169,13 @@ describe('task_result', () => {
122169
data: { ses_child1: { type: 'busy' } },
123170
});
124171

125-
await expect(
126-
tool.execute({ task_id: 'exp-1' }, {
127-
sessionID: 'parent-1',
128-
agent: 'orchestrator',
129-
} as any),
130-
).rejects.toThrow('still running');
172+
const output = await tool.execute({ task_id: 'exp-1' }, {
173+
sessionID: 'parent-1',
174+
agent: 'orchestrator',
175+
} as any);
131176

177+
expect(output).toContain('state: running');
178+
expect(output).toContain('task_status');
132179
expect(board.get('ses_child1')).toMatchObject({
133180
state: 'running',
134181
statusUncertain: false,
@@ -235,8 +282,7 @@ describe('task_result', () => {
235282
sessionID: 'parent-1',
236283
agent: 'orchestrator',
237284
} as any),
238-
).rejects.toThrow('still running');
239-
285+
).rejects.toThrow('changed generation');
240286
expect(board.get('ses_child1')).toMatchObject({
241287
generation: first.generation + 1,
242288
state: 'running',
@@ -280,8 +326,7 @@ describe('task_result', () => {
280326
sessionID: 'parent-1',
281327
agent: 'orchestrator',
282328
} as any),
283-
).rejects.toThrow('still running');
284-
329+
).rejects.toThrow('changed generation');
285330
expect(board.get('ses_child1')?.state).toBe('running');
286331
});
287332

@@ -309,33 +354,58 @@ describe('task_result', () => {
309354
expect(messages).not.toHaveBeenCalled();
310355
});
311356

312-
test('rejects an untracked child whose live session is busy', async () => {
357+
test('returns a status for an untracked child whose live session is busy', async () => {
313358
const { tool, messages } = createTool();
314359
mockClient.session.status.mockImplementation(async () => ({
315360
data: { ses_child1: { type: 'busy' } },
316361
}));
317362

363+
const output = await tool.execute({ task_id: 'ses_child1' }, {
364+
sessionID: 'parent-1',
365+
agent: 'orchestrator',
366+
} as any);
367+
368+
expect(output).toBe(
369+
[
370+
'task_id: ses_child1',
371+
'state: running',
372+
'message: Task is still running. Wait for its terminal result.',
373+
'next: retry task_result after the task finishes',
374+
].join('\n'),
375+
);
376+
expect(messages).not.toHaveBeenCalled();
377+
378+
mockClient.session.status.mockResolvedValue({ data: {} });
379+
mockClient.session.messages.mockResolvedValue({
380+
data: [
381+
{
382+
info: { role: 'assistant', time: { completed: 100 } },
383+
parts: [{ type: 'text', text: 'final findings' }],
384+
},
385+
],
386+
});
318387
await expect(
319388
tool.execute({ task_id: 'ses_child1' }, {
320389
sessionID: 'parent-1',
321390
agent: 'orchestrator',
322391
} as any),
323-
).rejects.toThrow('still running');
324-
expect(messages).not.toHaveBeenCalled();
392+
).resolves.toBe('final findings');
325393
});
326394

327-
test('rejects an untracked child whose live session is retrying', async () => {
395+
test('returns a status for an untracked child whose live session is retrying', async () => {
328396
const { tool, messages } = createTool();
329397
mockClient.session.status.mockImplementation(async () => ({
330398
data: { ses_child1: { type: 'retry' } },
331399
}));
332400

333-
await expect(
334-
tool.execute({ task_id: 'ses_child1' }, {
335-
sessionID: 'parent-1',
336-
agent: 'orchestrator',
337-
} as any),
338-
).rejects.toThrow('still running');
401+
const output = await tool.execute({ task_id: 'ses_child1' }, {
402+
sessionID: 'parent-1',
403+
agent: 'orchestrator',
404+
} as any);
405+
406+
expect(output).toContain('state: retry');
407+
expect(output).toContain('next: retry task_result after the task finishes');
408+
expect(output).not.toContain('task_status');
339409
expect(messages).not.toHaveBeenCalled();
340410
});
341411

src/tools/task-result.ts

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,17 @@ interface TaskResultToolOptions {
2626
/**
2727
* Gate tracked task retrieval on the tracked terminal outcome. Only a
2828
* `completed` state (or a reconciled job whose terminal outcome was
29-
* `completed`) may yield a successful result; running, errored and cancelled
30-
* jobs are rejected explicitly and accurately instead of leaking partial
31-
* output as a final result.
29+
* `completed`) may yield a successful result. Errored, cancelled and otherwise
30+
* unconfirmed jobs are rejected instead of leaking partial output as a final
31+
* result.
3232
*/
3333
function assertRetrievableState(
3434
requested: string,
3535
job: BackgroundJobRecord,
3636
): void {
37-
const terminalState =
38-
job.state === 'reconciled' ? job.terminalState : job.state;
37+
const terminalState = getTrackedTerminalState(job);
3938

40-
if (terminalState === 'running') {
41-
throw new Error(
42-
`Task ${requested} is still running. Wait for its terminal result instead of retrieving or duplicating it.`,
43-
);
44-
}
39+
if (terminalState === 'running') return;
4540
if (terminalState === 'error') {
4641
throw new Error(
4742
`Task ${requested} ended in error: ${job.lastStatusError ?? job.resultSummary ?? 'no error details available'}`,
@@ -58,6 +53,25 @@ function assertRetrievableState(
5853
}
5954
}
6055

56+
function getTrackedTerminalState(
57+
job: BackgroundJobRecord,
58+
): BackgroundJobRecord['state'] | BackgroundJobRecord['terminalState'] {
59+
return job.state === 'reconciled' ? job.terminalState : job.state;
60+
}
61+
62+
function formatRunningTaskStatus(
63+
taskID: string,
64+
state: 'running' | 'retry',
65+
tracked: boolean,
66+
): string {
67+
return [
68+
`task_id: ${taskID}`,
69+
`state: ${state}`,
70+
'message: Task is still running. Wait for its terminal result.',
71+
`next: ${tracked ? 'use task_status to inspect the task' : 'retry task_result after the task finishes'}`,
72+
].join('\n');
73+
}
74+
6175
function assertStableTrackedGeneration(
6276
requested: string,
6377
parentSessionID: string,
@@ -85,13 +99,11 @@ export function createTaskResultTool(
8599
options: TaskResultToolOptions,
86100
): Record<string, ToolDefinition> {
87101
const task_result = tool({
88-
description: `Retrieve the final text already produced by a specialist task without resuming or re-running it.
102+
description: `Retrieve the final text already produced by a specialist task, or inspect its active state without resuming or re-running it.
89103
90-
Use this when the user asks to see a prior task's full result, or before retrying work whose completed output may already answer the request. Accepts either the native task_id/session ID or the parent-scoped alias shown in the Background Job Board. This tool is read-only and never sends a new prompt to the specialist.`,
104+
Use this when the user asks to see a prior task's full result, or before retrying work whose completed output may already answer the request. If the task is still running, this returns a status message; only a completed task returns its final text. Accepts either the native task_id/session ID or the parent-scoped alias shown in the Background Job Board. This tool is read-only and never sends a new prompt to the specialist.`,
91105
args: {
92-
task_id: z
93-
.string()
94-
.describe('Completed task ID or Background Job Board alias'),
106+
task_id: z.string().describe('Task ID or Background Job Board alias'),
95107
},
96108
async execute(args, toolContext) {
97109
const parentSessionID = toolContext?.sessionID;
@@ -147,6 +159,19 @@ Use this when the user asks to see a prior task's full result, or before retryin
147159
throw new Error(`Unknown task ID or alias: ${requested}`);
148160
}
149161

162+
if (tracked && getTrackedTerminalState(tracked) === 'running') {
163+
liveSnapshot ??= await getRuntimeSessionStatusSnapshot(options.input);
164+
revalidateTracked();
165+
if (tracked && getTrackedTerminalState(tracked) === 'running') {
166+
const status = runtimeSessionStatus(liveSnapshot, taskID);
167+
return formatRunningTaskStatus(
168+
taskID,
169+
status === 'retry' ? 'retry' : 'running',
170+
true,
171+
);
172+
}
173+
}
174+
150175
const client = getClient(options.input);
151176
const sessionClient = client.session as typeof client.session & {
152177
get?: typeof client.session.get;
@@ -171,16 +196,19 @@ Use this when the user asks to see a prior task's full result, or before retryin
171196
revalidateTracked();
172197
const status = runtimeSessionStatus(liveSnapshot, taskID);
173198
const trackedTerminalState = tracked
174-
? tracked.state === 'reconciled'
175-
? tracked.terminalState
176-
: tracked.state
199+
? getTrackedTerminalState(tracked)
177200
: undefined;
178-
if (
179-
(status === 'busy' || status === 'retry') &&
180-
trackedTerminalState !== 'completed'
181-
) {
182-
throw new Error(
183-
`Task ${requested} is still running. Wait for its terminal result instead of retrieving or duplicating it.`,
201+
const activeState =
202+
status === 'retry'
203+
? 'retry'
204+
: status === 'busy' || trackedTerminalState === 'running'
205+
? 'running'
206+
: undefined;
207+
if (activeState !== undefined && trackedTerminalState !== 'completed') {
208+
return formatRunningTaskStatus(
209+
taskID,
210+
activeState,
211+
tracked !== undefined,
184212
);
185213
}
186214

0 commit comments

Comments
 (0)