Skip to content

Commit ab27b93

Browse files
Jiajun0413mhenke
authored andcommitted
fix(task-session): fill false-completed task parts with child session real output (alvinunreal#863)
When a foreground task's primary model halts on a non-retryable error (e.g. 403 quota exhausted), opencode's halt produces an empty assistant message and runTask settles the BackgroundJob as completed with an empty output (result.parts.findLast(text)?.text ?? ""). omos's tryFallback then re-prompts with the fallback model on an orphan runLoop that produces the real result, but the parent task part already says completed with an empty <task_result>. The orchestrator reads the empty output, mis-judges the task as failed/empty, and self-amplifies by launching redundant replacement tasks (alvinunreal#863). Fix: in the messages.transform hook, after stabilizeRunningTaskParts, scan task tool parts with status:completed whose parsed <task_result> is empty. For each, read the child session's real assistant text via the existing extractSessionResult helper and in-place rewrite the part output with renderTaskCompletedWithText (mirrors opencode renderOutput). Idempotent and non-blocking: if the child session has not yet produced non-empty text (fallback model still running), extractSessionResult returns empty and the part is left unchanged — the next transform turn re-evaluates naturally, so no explicit wait/poll is needed. The transform hook only mutates the in-memory output.messages (not the persisted DB), so the rewrite is per-turn but real output eventually lands in history once the child session completes and opencode writes it itself. Gated: only acts on completed task parts whose parsed result is empty. True completions with real text are preserved unchanged. Non-task tool parts are not touched. This is the false-complete counterpart to PR alvinunreal#880's false-cancel fix: - alvinunreal#880 rewrites error+cancelled parts using board record (false-cancel) - this rewrites completed+empty parts using child session text (false-complete) Root cause is in opencode runTask's "?? "" fallback, which omos cannot patch as a plugin. This is the minimal omos-only reconciliation. Co-developed-with: oracle (GLM-5.2)
1 parent ef4d8af commit ab27b93

4 files changed

Lines changed: 387 additions & 1 deletion

File tree

src/hooks/task-session-manager/board-injection.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* ../cache-safe-injection.ts to ensure prompt cache safety.
99
*/
1010
import { createHash } from 'node:crypto';
11+
import type { PluginInput } from '@opencode-ai/plugin';
1112
import type {
1213
BackgroundJobExecution,
1314
BackgroundJobInjectedCompletionFence,
@@ -26,7 +27,9 @@ import {
2627
parseTaskStatusOutput,
2728
renderRunningTaskPlaceholder,
2829
renderTaskTerminalFromBoard,
30+
renderTaskCompletedWithText,
2931
} from '../../utils';
32+
import { extractSessionResult } from '../../utils/session';
3033
import { isRecord } from '../../utils/guards';
3134
import { log } from '../../utils/logger';
3235
import {
@@ -630,6 +633,82 @@ export function reconcileFallbackFalseCancel(
630633
}
631634
}
632635

636+
/**
637+
* Reconcile false-completed foreground task tool parts.
638+
*
639+
* When a foreground task's primary model halts on a non-retryable error
640+
* (e.g. 403 quota exhausted), opencode's `halt` produces an empty assistant
641+
* message and `runTask` settles the BackgroundJob as `completed` with an
642+
* empty output (`result.parts.findLast(text)?.text ?? ""`). omos's
643+
* `tryFallback` then re-prompts with the fallback model on an orphan runLoop
644+
* that produces the real result, but the parent task part already says
645+
* `completed` with an empty `<task_result>` — the orchestrator reads the
646+
* empty output, mis-judges the task as failed/empty, and self-amplifies by
647+
* launching redundant replacement tasks (#863).
648+
*
649+
* This in-place rewrites such false-completed parts with the child session's
650+
* real assistant text once the fallback model has produced it. The pass is
651+
* idempotent and non-blocking: if the child session has not yet produced
652+
* non-empty text, `extractSessionResult` returns empty and the part is left
653+
* unchanged — the next transform turn re-evaluates naturally, so no explicit
654+
* wait/poll is needed. Because the transform hook only mutates the
655+
* in-memory `output.messages` (not the persisted DB), the rewrite is
656+
* per-turn but the real output eventually lands in history once the child
657+
* session completes and opencode writes it itself.
658+
*
659+
* Gated: only acts on `status:"completed"` task parts whose parsed
660+
* `<task_result>` is empty. True completions with real text are preserved
661+
* unchanged.
662+
*/
663+
export async function reconcileFalseCompleteFallback(
664+
state: InjectionState,
665+
messages: unknown[],
666+
client: PluginInput['client'],
667+
directory?: string,
668+
): Promise<void> {
669+
for (const message of messages) {
670+
if (!isMessageWithParts(message)) continue;
671+
for (const part of message.parts) {
672+
if (part.type !== 'tool' || part.tool !== 'task') continue;
673+
const partState = part.state;
674+
if (!isRecord(partState)) continue;
675+
if (partState.status !== 'completed') continue;
676+
if (typeof partState.output !== 'string') continue;
677+
678+
const status = parseTaskStatusOutput(partState.output);
679+
if (!status || status.state !== 'completed') continue;
680+
// Only reconcile empty results — real completions are intact.
681+
if (status.result && status.result.trim().length > 0) continue;
682+
683+
const childSessionId = status.taskID;
684+
const job = state.backgroundJobBoard.get(childSessionId);
685+
if (!job) continue;
686+
687+
// Read the child session's real assistant text. Non-blocking: returns
688+
// empty when the fallback model has not yet produced output, and the
689+
// next transform turn re-evaluates naturally (idempotent polling).
690+
const extracted = await extractSessionResult(client, childSessionId, {
691+
directory,
692+
includeReasoning: false,
693+
});
694+
if (extracted.empty) continue;
695+
696+
const summary = `Background task completed: ${job.description}`;
697+
partState.output = renderTaskCompletedWithText(
698+
childSessionId,
699+
summary,
700+
extracted.text,
701+
);
702+
log('[task-session-manager] reconciled false-completed task part', {
703+
taskID: childSessionId,
704+
alias: job.alias,
705+
parentSessionID: job.parentSessionID,
706+
textLength: extracted.text.length,
707+
});
708+
}
709+
}
710+
}
711+
633712
export function updateFromInjectedCompletion(
634713
state: InjectionState,
635714
part: MessagePart,
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { describe, expect, test, mock } from 'bun:test';
2+
import { BackgroundJobBoard } from '../../utils';
3+
import { createTaskSessionManagerHook } from './index';
4+
5+
const PARENT = 'parent-1';
6+
const CHILD = 'child-1';
7+
8+
function taskCompletedPart(
9+
callID: string,
10+
childSessionId: string,
11+
resultText = '',
12+
) {
13+
const tag = 'task_result';
14+
return {
15+
info: {
16+
role: 'assistant',
17+
agent: 'orchestrator',
18+
sessionID: PARENT,
19+
id: callID,
20+
},
21+
parts: [
22+
{ type: 'text', text: ' ' },
23+
{
24+
type: 'tool',
25+
tool: 'task',
26+
callID,
27+
state: {
28+
status: 'completed',
29+
output: [
30+
`<task id="${childSessionId}" state="completed">`,
31+
`<${tag}>`,
32+
resultText,
33+
`</${tag}>`,
34+
'</task>',
35+
].join('\n'),
36+
metadata: { sessionId: childSessionId },
37+
},
38+
},
39+
],
40+
};
41+
}
42+
43+
function userMessage(id: string, text: string) {
44+
return {
45+
info: { role: 'user', agent: 'orchestrator', sessionID: PARENT, id },
46+
parts: [{ type: 'text', text }],
47+
};
48+
}
49+
50+
function findTaskPart(messages: unknown[], callID: string) {
51+
for (const message of messages as { parts?: any[] }[]) {
52+
for (const part of message?.parts ?? []) {
53+
if (part?.type === 'tool' && part?.tool === 'task' && part?.callID === callID) {
54+
return part;
55+
}
56+
}
57+
}
58+
return undefined;
59+
}
60+
61+
async function transform(
62+
hook: ReturnType<typeof createTaskSessionManagerHook>,
63+
history: unknown[],
64+
) {
65+
const request = { messages: structuredClone(history) };
66+
await hook['experimental.chat.messages.transform']({}, request as never);
67+
return request.messages;
68+
}
69+
70+
function setupCompletedBoard(board: BackgroundJobBoard, description = 'test task') {
71+
board.registerLaunch({
72+
taskID: CHILD,
73+
parentSessionID: PARENT,
74+
agent: 'fixer',
75+
description,
76+
});
77+
}
78+
79+
function mockClient(childMessages: Array<{ info?: { role: string }; parts?: Array<{ type: string; text?: string }> }>) {
80+
return {
81+
session: {
82+
messages: mock(async () => ({ data: childMessages })),
83+
status: mock(async () => ({ data: {} })),
84+
},
85+
};
86+
}
87+
88+
describe('reconcileFalseCompleteFallback', () => {
89+
test('fills empty completed output with child session real assistant text', async () => {
90+
const board = new BackgroundJobBoard();
91+
setupCompletedBoard(board, 'audit DATA.md');
92+
// Child session has produced a real 9346-char report
93+
const childMessages = [
94+
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'audit' }] },
95+
{
96+
info: { role: 'assistant' },
97+
parts: [{ type: 'text', text: '# Audit Report\nAll claims verified.' }],
98+
},
99+
];
100+
const hook = createTaskSessionManagerHook(
101+
{ client: mockClient(childMessages), directory: '/tmp' } as never,
102+
{
103+
maxSessionsPerAgent: 2,
104+
maxRetainedSnapshots: 2,
105+
backgroundJobBoard: board,
106+
shouldManageSession: () => true,
107+
},
108+
);
109+
110+
const history = [
111+
userMessage('u1', 'run audit'),
112+
taskCompletedPart('call-1', CHILD, ''), // empty result = false-complete
113+
];
114+
115+
const result = await transform(hook, history);
116+
const part = findTaskPart(result, 'call-1') as any;
117+
118+
expect(part.state.status).toBe('completed');
119+
expect(part.state.output).toContain('state="completed"');
120+
expect(part.state.output).toContain('Background task completed: audit DATA.md');
121+
expect(part.state.output).toContain('# Audit Report');
122+
expect(part.state.output).toContain('All claims verified.');
123+
});
124+
125+
test('does NOT rewrite when completed output already has real text', async () => {
126+
const board = new BackgroundJobBoard();
127+
setupCompletedBoard(board, 'test task');
128+
const childMessages = [
129+
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'real output' }] },
130+
];
131+
const hook = createTaskSessionManagerHook(
132+
{ client: mockClient(childMessages), directory: '/tmp' } as never,
133+
{
134+
maxSessionsPerAgent: 2,
135+
maxRetainedSnapshots: 2,
136+
backgroundJobBoard: board,
137+
shouldManageSession: () => true,
138+
},
139+
);
140+
141+
const realText = 'Already have real result here';
142+
const history = [
143+
userMessage('u1', 'run task'),
144+
taskCompletedPart('call-1', CHILD, realText),
145+
];
146+
147+
const result = await transform(hook, history);
148+
const part = findTaskPart(result, 'call-1') as any;
149+
150+
// Real completion is preserved unchanged.
151+
expect(part.state.output).toContain(realText);
152+
expect(part.state.output).not.toContain('Background task completed:');
153+
});
154+
155+
test('does NOT rewrite when child session has no assistant text yet (still running)', async () => {
156+
const board = new BackgroundJobBoard();
157+
setupCompletedBoard(board, 'test task');
158+
// Child session has no assistant text — fallback model still running
159+
const childMessages = [
160+
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'prompt' }] },
161+
];
162+
const hook = createTaskSessionManagerHook(
163+
{ client: mockClient(childMessages), directory: '/tmp' } as never,
164+
{
165+
maxSessionsPerAgent: 2,
166+
maxRetainedSnapshots: 2,
167+
backgroundJobBoard: board,
168+
shouldManageSession: () => true,
169+
},
170+
);
171+
172+
const history = [
173+
userMessage('u1', 'run task'),
174+
taskCompletedPart('call-1', CHILD, ''),
175+
];
176+
177+
const result = await transform(hook, history);
178+
const part = findTaskPart(result, 'call-1') as any;
179+
180+
// No real text available yet — part stays empty (next turn re-evaluates).
181+
expect(part.state.status).toBe('completed');
182+
const tagMatch = /<task_result>\s*([\s\S]*?)\s*<\/task_result>/m.exec(
183+
part.state.output,
184+
);
185+
expect(tagMatch?.[1]?.trim()).toBe('');
186+
});
187+
188+
test('is idempotent across consecutive transforms', async () => {
189+
const board = new BackgroundJobBoard();
190+
setupCompletedBoard(board, 'test task');
191+
const childMessages = [
192+
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'real output' }] },
193+
];
194+
const hook = createTaskSessionManagerHook(
195+
{ client: mockClient(childMessages), directory: '/tmp' } as never,
196+
{
197+
maxSessionsPerAgent: 2,
198+
maxRetainedSnapshots: 2,
199+
backgroundJobBoard: board,
200+
shouldManageSession: () => true,
201+
},
202+
);
203+
204+
const history = [
205+
userMessage('u1', 'run task'),
206+
taskCompletedPart('call-1', CHILD, ''),
207+
];
208+
209+
const first = await transform(hook, history);
210+
const firstOutput = findTaskPart(first, 'call-1').state.output;
211+
212+
const second = await transform(hook, history);
213+
const secondOutput = findTaskPart(second, 'call-1').state.output;
214+
215+
expect(secondOutput).toBe(firstOutput);
216+
});
217+
218+
test('does not touch non-task tool parts', async () => {
219+
const board = new BackgroundJobBoard();
220+
setupCompletedBoard(board, 'test task');
221+
const childMessages = [
222+
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'real' }] },
223+
];
224+
const hook = createTaskSessionManagerHook(
225+
{ client: mockClient(childMessages), directory: '/tmp' } as never,
226+
{
227+
maxSessionsPerAgent: 2,
228+
maxRetainedSnapshots: 2,
229+
backgroundJobBoard: board,
230+
shouldManageSession: () => true,
231+
},
232+
);
233+
234+
const history = [
235+
userMessage('u1', 'run task'),
236+
{
237+
info: { role: 'assistant', agent: 'orchestrator', sessionID: PARENT, id: 'call-2' },
238+
parts: [
239+
{ type: 'text', text: ' ' },
240+
{
241+
type: 'tool',
242+
tool: 'read',
243+
callID: 'call-2',
244+
state: {
245+
status: 'completed',
246+
output: '<task id="child-1" state="completed"><task_result></task_result></task>',
247+
metadata: { sessionId: CHILD },
248+
},
249+
},
250+
],
251+
},
252+
];
253+
254+
const result = await transform(hook, history);
255+
const part = result[1].parts[1] as any;
256+
257+
// read tool parts are not reconciled.
258+
expect(part.state.output).toContain('<task_result></task_result>');
259+
});
260+
});

src/hooks/task-session-manager/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import {
2222
type InjectionState,
2323
injectBackgroundJobBoard,
2424
observeSyntheticTerminalPart,
25-
MAX_PROCESSED_INJECTED_COMPLETIONS,
2625
reconcileFallbackFalseCancel,
26+
reconcileFalseCompleteFallback,
2727
reconcileInjectedTerminalJobs,
2828
stabilizeRunningTaskParts,
2929
updateFromInjectedCompletion,
@@ -460,6 +460,20 @@ export function createTaskSessionManagerHook(
460460
rehydrateTombstones,
461461
);
462462

463+
// Reconcile false-completed foreground task parts: when the primary
464+
// model halts on a non-retryable error, opencode settles the task as
465+
// completed with an empty output while the fallback model still
466+
// produces the real result on an orphan runLoop (#863). Fill the empty
467+
// output with the child session's real assistant text once available.
468+
// Idempotent and non-blocking — empty extractions are skipped and
469+
// re-evaluated on the next transform turn.
470+
await reconcileFalseCompleteFallback(
471+
injectionState,
472+
messages,
473+
_ctx.client,
474+
_ctx.directory,
475+
);
476+
463477
for (const [messageIndex, message] of messages.entries()) {
464478
if (!isUserMessageWithParts(message)) continue;
465479
if (message.info.agent && message.info.agent !== 'orchestrator') {

0 commit comments

Comments
 (0)