Skip to content

Commit f3bea6d

Browse files
committed
fix: implement robust session exit condition for reasoning models and tool execution
- Add comprehensive exit logic handling reasoning blocks, text content, and tool calls - Prevent premature exit on reasoning-only messages (thinking/thoughts) - Properly handle combinations: reasoning+text, text+tools, reasoning+tools - Support both separated thinking (reasoning blocks) and interleaved thinking models - Check for pending or running tools before exiting session processing loop - Add hasRunningOrPendingTools() function with defensive error handling - Fix TypeScript type inference issues with MessageV2.User and MessageV2.WithParts - Add explicit type casting for proper property access and message comparison This makes session processing more reliable across different AI model types and response patterns, especially for reasoning-capable models. The fix ensures tools complete execution before considering the session done, regardless of execution pattern (sequential or parallel).
1 parent 49800a0 commit f3bea6d

File tree

1 file changed

+68
-26
lines changed

1 file changed

+68
-26
lines changed

packages/opencode/src/session/prompt.ts

Lines changed: 68 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -250,15 +250,20 @@ export namespace SessionPrompt {
250250
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
251251

252252
let lastUser: MessageV2.User | undefined
253-
let lastAssistant: MessageV2.Assistant | undefined
253+
let lastAssistant: MessageV2.WithParts | undefined
254254
let lastFinished: MessageV2.Assistant | undefined
255255
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
256256
for (let i = msgs.length - 1; i >= 0; i--) {
257257
const msg = msgs[i]
258-
if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User
259-
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant
260-
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish)
258+
if (!lastUser && msg.info.role === "user") {
259+
lastUser = msg.info as MessageV2.User
260+
}
261+
if (!lastAssistant && msg.info.role === "assistant") {
262+
lastAssistant = msg
263+
}
264+
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) {
261265
lastFinished = msg.info as MessageV2.Assistant
266+
}
262267
if (lastUser && lastFinished) break
263268
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
264269
if (task && !lastFinished) {
@@ -267,13 +272,38 @@ export namespace SessionPrompt {
267272
}
268273

269274
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
270-
if (
271-
lastAssistant?.finish &&
272-
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
273-
lastUser.id < lastAssistant.id
274-
) {
275-
log.info("exiting loop", { sessionID })
276-
break
275+
276+
// Exit Loop When:
277+
// • Text + Reasoning → Complete response ready
278+
// • Text Only → Normal response ready
279+
//
280+
// Continue Loop When:
281+
// • Reasoning Only → Wait for text content
282+
// • Any tool calls pending → Tools must execute first
283+
// • Text + Tools → Tools executing needed
284+
// • Invalid/error state → Unexpected condition
285+
286+
const hasReasoningBlocks = lastAssistant?.parts.some((part) => part.type === "reasoning")
287+
const hasTextContent = lastAssistant?.parts.some((part) => part.type === "text")
288+
const hasOnlyReasoning = hasReasoningBlocks && !hasTextContent
289+
290+
// Check if there are pending or running tools for the last assistant message
291+
const hasPendingTools = lastAssistant
292+
? await hasRunningOrPendingTools((lastAssistant.info as MessageV2.Assistant).id)
293+
: false
294+
295+
if (lastAssistant?.info.role === "assistant" && lastAssistant.info.finish) {
296+
const hasToolCalls = lastAssistant.info.finish === "tool-calls"
297+
const hasTextAndToolCalls = hasTextContent && hasToolCalls
298+
const hasValidFinish = !["tool-calls", "unknown"].includes(lastAssistant.info.finish)
299+
const hasValidIds = (lastUser as MessageV2.User).id < (lastAssistant.info as MessageV2.Assistant).id
300+
const shouldExit =
301+
hasValidFinish && hasValidIds && !hasOnlyReasoning && !hasTextAndToolCalls && !hasPendingTools
302+
303+
if (shouldExit) {
304+
log.info("exiting loop", { sessionID })
305+
break
306+
}
277307
}
278308

279309
step++
@@ -360,7 +390,7 @@ export namespace SessionPrompt {
360390
},
361391
},
362392
)
363-
.catch(() => {})
393+
.catch(() => { })
364394
assistantMessage.finish = "tool-calls"
365395
assistantMessage.time.completed = Date.now()
366396
await Session.updateMessage(assistantMessage)
@@ -539,10 +569,10 @@ export namespace SessionPrompt {
539569
headers: {
540570
...(model.providerID.startsWith("opencode")
541571
? {
542-
"x-opencode-project": Instance.project.id,
543-
"x-opencode-session": sessionID,
544-
"x-opencode-request": lastUser.id,
545-
}
572+
"x-opencode-project": Instance.project.id,
573+
"x-opencode-session": sessionID,
574+
"x-opencode-request": lastUser.id,
575+
}
546576
: undefined),
547577
...model.headers,
548578
},
@@ -914,7 +944,7 @@ export namespace SessionPrompt {
914944
agent: input.agent!,
915945
messageID: info.id,
916946
extra: { bypassCwdCheck: true, model },
917-
metadata: async () => {},
947+
metadata: async () => { },
918948
})
919949
pieces.push({
920950
id: Identifier.ascending("part"),
@@ -974,7 +1004,7 @@ export namespace SessionPrompt {
9741004
agent: input.agent!,
9751005
messageID: info.id,
9761006
extra: { bypassCwdCheck: true },
977-
metadata: async () => {},
1007+
metadata: async () => { },
9781008
}),
9791009
)
9801010
return [
@@ -1377,14 +1407,14 @@ export namespace SessionPrompt {
13771407
const parts =
13781408
(agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
13791409
? [
1380-
{
1381-
type: "subtask" as const,
1382-
agent: agent.name,
1383-
description: command.description ?? "",
1384-
// TODO: how can we make task tool accept a more complex input?
1385-
prompt: await resolvePromptParts(template).then((x) => x.find((y) => y.type === "text")?.text ?? ""),
1386-
},
1387-
]
1410+
{
1411+
type: "subtask" as const,
1412+
agent: agent.name,
1413+
description: command.description ?? "",
1414+
// TODO: how can we make task tool accept a more complex input?
1415+
prompt: await resolvePromptParts(template).then((x) => x.find((y) => y.type === "text")?.text ?? ""),
1416+
},
1417+
]
13881418
: await resolvePromptParts(template)
13891419

13901420
const result = (await prompt({
@@ -1405,6 +1435,18 @@ export namespace SessionPrompt {
14051435
return result
14061436
}
14071437

1438+
async function hasRunningOrPendingTools(messageID: string): Promise<boolean> {
1439+
try {
1440+
const parts = await MessageV2.parts(messageID)
1441+
return parts.some(
1442+
(part) => part.type === "tool" && (part.state.status === "running" || part.state.status === "pending"),
1443+
)
1444+
} catch {
1445+
// If we can't read the parts, assume no pending tools
1446+
return false
1447+
}
1448+
}
1449+
14081450
async function ensureTitle(input: {
14091451
session: Session.Info
14101452
message: MessageV2.WithParts

0 commit comments

Comments
 (0)