Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/hooks/task-session-manager/board-injection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* All injection logic must go through the cache-safe helpers in
* ../cache-safe-injection.ts to ensure prompt cache safety.
*/
import type { PluginInput } from '@opencode-ai/plugin';
import type {
BackgroundJobRecord,
BackgroundJobStore,
Expand All @@ -16,7 +17,9 @@ import {
isInternalInitiatorPart,
parseTaskStatusOutput,
renderRunningTaskPlaceholder,
renderTaskCompletedWithText,
} from '../../utils';
import { extractSessionResult } from '../../utils/session';
import { isRecord } from '../../utils/guards';
import { log } from '../../utils/logger';
import {
Expand Down Expand Up @@ -163,6 +166,82 @@ export function stabilizeRunningTaskParts(messages: unknown[]): void {
}
}

/**
* Reconcile false-completed foreground task tool parts.
*
* 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 (#863).
*
* This in-place rewrites such false-completed parts with the child session's
* real assistant text once the fallback model has produced it. The pass is
* idempotent and non-blocking: if the child session has not yet produced
* non-empty text, `extractSessionResult` returns empty and the part is left
* unchanged — the next transform turn re-evaluates naturally, so no explicit
* wait/poll is needed. Because the transform hook only mutates the
* in-memory `output.messages` (not the persisted DB), the rewrite is
* per-turn but the real output eventually lands in history once the child
* session completes and opencode writes it itself.
*
* Gated: only acts on `status:"completed"` task parts whose parsed
* `<task_result>` is empty. True completions with real text are preserved
* unchanged.
*/
export async function reconcileFalseCompleteFallback(
state: InjectionState,
messages: unknown[],
client: PluginInput['client'],
directory?: string,
): Promise<void> {
for (const message of messages) {
if (!isMessageWithParts(message)) continue;
for (const part of message.parts) {
if (part.type !== 'tool' || part.tool !== 'task') continue;
const partState = part.state;
if (!isRecord(partState)) continue;
if (partState.status !== 'completed') continue;
if (typeof partState.output !== 'string') continue;

const status = parseTaskStatusOutput(partState.output);
if (!status || status.state !== 'completed') continue;
// Only reconcile empty results — real completions are intact.
if (status.result && status.result.trim().length > 0) continue;

const childSessionId = status.taskID;
const job = state.backgroundJobBoard.get(childSessionId);
if (!job) continue;

// Read the child session's real assistant text. Non-blocking: returns
// empty when the fallback model has not yet produced output, and the
// next transform turn re-evaluates naturally (idempotent polling).
const extracted = await extractSessionResult(client, childSessionId, {
directory,
includeReasoning: false,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Child lookup aborts transforms

When the child-session messages request fails because of a transient client error, unavailable service, or missing session, the uncaught exception propagates through the new reconciliation pass and aborts the parent message transform instead of preserving the empty result for a later retry.

if (extracted.empty) continue;

const summary = `Background task completed: ${job.description}`;
partState.output = renderTaskCompletedWithText(
childSessionId,
summary,
extracted.text,
);
log('[task-session-manager] reconciled false-completed task part', {
taskID: childSessionId,
alias: job.alias,
parentSessionID: job.parentSessionID,
textLength: extracted.text.length,
});
}
}
}

export function updateFromInjectedCompletion(
state: InjectionState,
part: MessagePart,
Expand Down
260 changes: 260 additions & 0 deletions src/hooks/task-session-manager/false-complete-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { describe, expect, test, mock } from 'bun:test';
import { BackgroundJobBoard } from '../../utils';
import { createTaskSessionManagerHook } from './index';

const PARENT = 'parent-1';
const CHILD = 'child-1';

function taskCompletedPart(
callID: string,
childSessionId: string,
resultText = '',
) {
const tag = 'task_result';
return {
info: {
role: 'assistant',
agent: 'orchestrator',
sessionID: PARENT,
id: callID,
},
parts: [
{ type: 'text', text: ' ' },
{
type: 'tool',
tool: 'task',
callID,
state: {
status: 'completed',
output: [
`<task id="${childSessionId}" state="completed">`,
`<${tag}>`,
resultText,
`</${tag}>`,
'</task>',
].join('\n'),
metadata: { sessionId: childSessionId },
},
},
],
};
}

function userMessage(id: string, text: string) {
return {
info: { role: 'user', agent: 'orchestrator', sessionID: PARENT, id },
parts: [{ type: 'text', text }],
};
}

function findTaskPart(messages: unknown[], callID: string) {
for (const message of messages as { parts?: any[] }[]) {
for (const part of message?.parts ?? []) {
if (part?.type === 'tool' && part?.tool === 'task' && part?.callID === callID) {
return part;
}
}
}
return undefined;
}

async function transform(
hook: ReturnType<typeof createTaskSessionManagerHook>,
history: unknown[],
) {
const request = { messages: structuredClone(history) };
await hook['experimental.chat.messages.transform']({}, request as never);
return request.messages;
}

function setupCompletedBoard(board: BackgroundJobBoard, description = 'test task') {
board.registerLaunch({
taskID: CHILD,
parentSessionID: PARENT,
agent: 'fixer',
description,
});
}

function mockClient(childMessages: Array<{ info?: { role: string }; parts?: Array<{ type: string; text?: string }> }>) {
return {
session: {
messages: mock(async () => ({ data: childMessages })),
status: mock(async () => ({ data: {} })),
},
};
}

describe('reconcileFalseCompleteFallback', () => {
test('fills empty completed output with child session real assistant text', async () => {
const board = new BackgroundJobBoard();
setupCompletedBoard(board, 'audit DATA.md');
// Child session has produced a real 9346-char report
const childMessages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'audit' }] },
{
info: { role: 'assistant' },
parts: [{ type: 'text', text: '# Audit Report\nAll claims verified.' }],
},
];
const hook = createTaskSessionManagerHook(
{ client: mockClient(childMessages), directory: '/tmp' } as never,
{
maxSessionsPerAgent: 2,
maxRetainedSnapshots: 2,
backgroundJobBoard: board,
shouldManageSession: () => true,
},
);

const history = [
userMessage('u1', 'run audit'),
taskCompletedPart('call-1', CHILD, ''), // empty result = false-complete
];

const result = await transform(hook, history);
const part = findTaskPart(result, 'call-1') as any;

expect(part.state.status).toBe('completed');
expect(part.state.output).toContain('state="completed"');
expect(part.state.output).toContain('Background task completed: audit DATA.md');
expect(part.state.output).toContain('# Audit Report');
expect(part.state.output).toContain('All claims verified.');
});

test('does NOT rewrite when completed output already has real text', async () => {
const board = new BackgroundJobBoard();
setupCompletedBoard(board, 'test task');
const childMessages = [
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'real output' }] },
];
const hook = createTaskSessionManagerHook(
{ client: mockClient(childMessages), directory: '/tmp' } as never,
{
maxSessionsPerAgent: 2,
maxRetainedSnapshots: 2,
backgroundJobBoard: board,
shouldManageSession: () => true,
},
);

const realText = 'Already have real result here';
const history = [
userMessage('u1', 'run task'),
taskCompletedPart('call-1', CHILD, realText),
];

const result = await transform(hook, history);
const part = findTaskPart(result, 'call-1') as any;

// Real completion is preserved unchanged.
expect(part.state.output).toContain(realText);
expect(part.state.output).not.toContain('Background task completed:');
});

test('does NOT rewrite when child session has no assistant text yet (still running)', async () => {
const board = new BackgroundJobBoard();
setupCompletedBoard(board, 'test task');
// Child session has no assistant text — fallback model still running
const childMessages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'prompt' }] },
];
const hook = createTaskSessionManagerHook(
{ client: mockClient(childMessages), directory: '/tmp' } as never,
{
maxSessionsPerAgent: 2,
maxRetainedSnapshots: 2,
backgroundJobBoard: board,
shouldManageSession: () => true,
},
);

const history = [
userMessage('u1', 'run task'),
taskCompletedPart('call-1', CHILD, ''),
];

const result = await transform(hook, history);
const part = findTaskPart(result, 'call-1') as any;

// No real text available yet — part stays empty (next turn re-evaluates).
expect(part.state.status).toBe('completed');
const tagMatch = /<task_result>\s*([\s\S]*?)\s*<\/task_result>/m.exec(
part.state.output,
);
expect(tagMatch?.[1]?.trim()).toBe('');
});

test('is idempotent across consecutive transforms', async () => {
const board = new BackgroundJobBoard();
setupCompletedBoard(board, 'test task');
const childMessages = [
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'real output' }] },
];
const hook = createTaskSessionManagerHook(
{ client: mockClient(childMessages), directory: '/tmp' } as never,
{
maxSessionsPerAgent: 2,
maxRetainedSnapshots: 2,
backgroundJobBoard: board,
shouldManageSession: () => true,
},
);

const history = [
userMessage('u1', 'run task'),
taskCompletedPart('call-1', CHILD, ''),
];

const first = await transform(hook, history);
const firstOutput = findTaskPart(first, 'call-1').state.output;

const second = await transform(hook, history);
const secondOutput = findTaskPart(second, 'call-1').state.output;

expect(secondOutput).toBe(firstOutput);
});

test('does not touch non-task tool parts', async () => {
const board = new BackgroundJobBoard();
setupCompletedBoard(board, 'test task');
const childMessages = [
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'real' }] },
];
const hook = createTaskSessionManagerHook(
{ client: mockClient(childMessages), directory: '/tmp' } as never,
{
maxSessionsPerAgent: 2,
maxRetainedSnapshots: 2,
backgroundJobBoard: board,
shouldManageSession: () => true,
},
);

const history = [
userMessage('u1', 'run task'),
{
info: { role: 'assistant', agent: 'orchestrator', sessionID: PARENT, id: 'call-2' },
parts: [
{ type: 'text', text: ' ' },
{
type: 'tool',
tool: 'read',
callID: 'call-2',
state: {
status: 'completed',
output: '<task id="child-1" state="completed"><task_result></task_result></task>',
metadata: { sessionId: CHILD },
},
},
],
},
];

const result = await transform(hook, history);
const part = result[1].parts[1] as any;

// read tool parts are not reconciled.
expect(part.state.output).toContain('<task_result></task_result>');
});
});
15 changes: 15 additions & 0 deletions src/hooks/task-session-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type InjectionState,
injectBackgroundJobBoard,
MAX_PROCESSED_INJECTED_COMPLETIONS,
reconcileFalseCompleteFallback,
reconcileInjectedTerminalJobs,
stabilizeRunningTaskParts,
updateFromInjectedCompletion,
Expand Down Expand Up @@ -278,6 +279,20 @@ export function createTaskSessionManagerHook(
// cache. Terminal results are left untouched (they materialize once).
stabilizeRunningTaskParts(messages);

// Reconcile false-completed foreground task parts: when the primary
// model halts on a non-retryable error, opencode settles the task as
// completed with an empty output while the fallback model still
// produces the real result on an orphan runLoop (#863). Fill the empty
// output with the child session's real assistant text once available.
// Idempotent and non-blocking — empty extractions are skipped and
// re-evaluated on the next transform turn.
await reconcileFalseCompleteFallback(
injectionState,
messages,
_ctx.client,
_ctx.directory,
);

for (const [messageIndex, message] of messages.entries()) {
if (!isUserMessageWithParts(message)) continue;
if (message.info.agent && message.info.agent !== 'orchestrator') {
Expand Down
Loading
Loading