diff --git a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts index 2bba62fa8..be7dd2a3a 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts @@ -421,6 +421,134 @@ describe('OpenCodeServerHarness', () => { } }); + it.each(['session.status', 'session.idle'] as const)( + 'keeps the turn active when %s arrives while a command is running', + async (idleEventType) => { + const { client, harness } = createHarness(); + const taskEvents: TaskEvent[] = []; + + harness.subscribe((event) => taskEvents.push(event)); + + try { + await connectHarness(harness, client); + + expect( + harness.sendCommand({ + commandName: TaskCommandName.StartNewTask, + data: { text: 'Run a command.', visibleInTranscript: true }, + }), + ).toBe(true); + + await vi.waitFor(() => { + expect(client.promptAsync).toHaveBeenCalledTimes(1); + }); + + const runningToolPart = { + id: 'tool_part_1', + sessionID: 'ses_1', + messageID: 'msg_1', + type: 'tool' as const, + callID: 'call_1', + tool: 'bash', + state: { + status: 'running', + input: { command: 'sleep 60' }, + title: 'Run shell command', + }, + }; + + await client.emit({ + type: 'message.part.updated', + properties: { part: runningToolPart }, + }); + await client.emit( + idleEventType === 'session.status' + ? { + type: 'session.status', + properties: { + sessionID: 'ses_1', + status: { type: 'idle' }, + }, + } + : { + type: 'session.idle', + properties: { sessionID: 'ses_1' }, + }, + ); + + expect( + taskEvents.some( + (event) => event.eventName === TaskEventName.TaskCompleted, + ), + ).toBe(false); + + await client.emit({ + type: 'message.part.updated', + properties: { + part: { + ...runningToolPart, + state: { + ...runningToolPart.state, + status: 'completed', + output: '', + }, + }, + }, + }); + client.messages.mockResolvedValue([createFinalAssistantMessage()]); + + await client.emit({ + type: 'session.idle', + properties: { sessionID: 'ses_1' }, + }); + + expect( + taskEvents.some( + (event) => event.eventName === TaskEventName.TaskCompleted, + ), + ).toBe(true); + } finally { + harness.dispose(); + } + }, + ); + + it('keeps the turn active when idle work verification fails', async () => { + const { client, harness } = createHarness(); + const taskEvents: TaskEvent[] = []; + + harness.subscribe((event) => taskEvents.push(event)); + + try { + await connectHarness(harness, client); + + expect( + harness.sendCommand({ + commandName: TaskCommandName.StartNewTask, + data: { text: 'Use a tool.', visibleInTranscript: true }, + }), + ).toBe(true); + + await vi.waitFor(() => { + expect(client.promptAsync).toHaveBeenCalledTimes(1); + }); + + client.messages.mockRejectedValueOnce(new Error('connection reset')); + await client.emit({ + type: 'session.idle', + properties: { sessionID: 'ses_1' }, + }); + + expect( + taskEvents.some( + (event) => event.eventName === TaskEventName.TaskCompleted, + ), + ).toBe(false); + } finally { + harness.dispose(); + } + }); + it('records inference usage for completed child-session (subagent) assistant messages', async () => { const { client, harness } = createHarness(); const inferenceUsageEvents: HarnessInferenceUsageEvent[] = []; diff --git a/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts b/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts index 6ef9b863b..2acb4fb5f 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts @@ -4583,10 +4583,20 @@ export class OpenCodeServerHarness return; } + const sessionId = this.sessionId; + + if (sessionId && (await this.hasUnsettledToolWork(sessionId))) { + // OpenCode can emit an idle transition while a command is still running. + // Keep the turn active so consumers do not start the sleep countdown. + this.logger.info( + `Ignoring stale OpenCode idle event while the session still has unsettled tool work source=${source} sessionId=${sessionId}`, + ); + return; + } + const finalized = (await this.finalizeLatestAssistantMessage()) ?? this.finalizedAssistantTurn; - const sessionId = this.sessionId; this.inFlight = false; this.finalizedAssistantTurn = null; @@ -4625,21 +4635,6 @@ export class OpenCodeServerHarness `OpenCode Slack closeout hook still blocked after ${MAX_OPENCODE_STOP_HOOK_REMINDERS} reminders; completing the turn without a Slack closeout reason=${reason}`, ); } else { - if (await this.hasUnsettledToolWork(sessionId)) { - // The idle that triggered enforcement was stale: the session - // still has a tool call pending/running or an assistant message - // streaming. A reminder submitted now lands mid-work and reads - // as an instruction to drop that work, so defer to the next - // genuine idle. Restore inFlight so that idle passes the entry - // guard, and arm the fail-safe in case it never arrives. - this.logger.info( - `OpenCode Slack closeout reminder deferred: the session still has unsettled tool work sessionId=${sessionId}`, - ); - this.inFlight = true; - this.armStopHookReminderStall(sessionId); - return; - } - this.stopHookReminderCount += 1; this.logger.info( `OpenCode Slack closeout reminder submitted count=${this.stopHookReminderCount} source=${source} sessionId=${sessionId}`, @@ -4809,13 +4804,18 @@ export class OpenCodeServerHarness /** * Whether the session's recent messages show work still in progress: a tool * part in pending/running state, or the newest assistant message not yet - * completed. Used to keep the closeout reminder from being injected into a - * session whose idle signal was stale, where the reminder would land - * mid-work and supersede the in-flight tool call. Verification failures return false - * (proceed with the reminder) so a transient fetch error cannot silently - * stall closeout enforcement. + * completed. Used to reject stale idle signals so the task remains active + * until the current tool call and assistant turn actually settle. */ private async hasUnsettledToolWork(sessionId: string): Promise { + if ( + [...this.activeExecuteToolProgress.values()].some( + (progress) => progress.sessionId === sessionId, + ) + ) { + return true; + } + let messages: OpenCodeSessionMessage[]; try { @@ -4826,11 +4826,11 @@ export class OpenCodeServerHarness }); } catch (error) { this.logger.warn( - `OpenCode closeout quiescence check failed; proceeding with the reminder sessionId=${sessionId}: ${ + `OpenCode idle quiescence check failed; keeping the turn active sessionId=${sessionId}: ${ error instanceof Error ? error.message : String(error) }`, ); - return false; + return true; } const latestAssistantMessage = [...messages]