diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2fc93c4825..936b0ff039 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -81,6 +81,12 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc const log = Log.create({ service: "session.prompt" }) const elog = EffectLogger.create({ service: "session.prompt" }) +function isOrphanedInterruptedTool(part: MessageV2.ToolPart) { + // cleanup() marks abandoned tool_use blocks this way after retries/aborts. + // They are not pending work and must not trigger an assistant-prefill request. + return part.state.status === "error" && part.state.metadata?.interrupted === true +} + export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect @@ -1257,12 +1263,13 @@ export const layer = Layer.effect( const lastAssistantMsg = msgs.findLast( (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id, ) - // Some providers return "stop" even when the assistant message contains tool calls. - // Keep the loop running so tool results can be sent back to the model. - // Skip provider-executed tool parts — those were fully handled within the - // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop. + // Some providers return "stop" even when the assistant message contains + // tool calls. Keep the loop running so tool results can be sent back to + // the model, but ignore cleanup-marked interrupted orphans. const hasToolCalls = - lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false + lastAssistantMsg?.parts.some( + (part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part), + ) ?? false if ( lastAssistant?.finish && @@ -1270,6 +1277,16 @@ export const layer = Layer.effect( !hasToolCalls && lastUser.id < lastAssistant.id ) { + const orphan = lastAssistantMsg?.parts.find( + (part): part is MessageV2.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part), + ) + if (orphan) { + yield* slog.warn("loop exit with orphaned interrupted tool", { + messageID: lastAssistant.id, + tool: orphan.tool, + callID: orphan.callID, + }) + } yield* slog.info("exiting loop") break } diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index ff9ded4d19..4c46474578 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -457,6 +457,35 @@ noLLMServer.instance( { config: cfg }, ) +it.instance("loop exits without an LLM request for interrupted orphan tool calls", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + const seeded = yield* seed(chat.id, { finish: "stop" }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: seeded.assistant.id, + sessionID: chat.id, + type: "tool", + callID: "interrupted-call", + tool: "edit", + state: { + status: "error", + input: {}, + error: "Tool execution aborted", + metadata: { interrupted: true }, + time: { start: 1, end: 2 }, + }, + }) + + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(result.info.id).toBe(seeded.assistant.id) + expect(yield* llm.hits).toHaveLength(0) + }), +) + it.instance("loop calls LLM and returns assistant message", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg)