Skip to content

Commit ec6a326

Browse files
Apply PR #14307: fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering
2 parents 8204bd5 + 291e168 commit ec6a326

5 files changed

Lines changed: 220 additions & 15 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, test } from "bun:test"
2+
import type { Message } from "@opencode-ai/sdk/v2/client"
3+
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
4+
5+
function user(id: string): Message {
6+
return {
7+
id,
8+
role: "user",
9+
sessionID: "session-1",
10+
time: { created: 1 },
11+
} as unknown as Message
12+
}
13+
14+
function assistant(id: string, parentID: string): Message {
15+
return {
16+
id,
17+
role: "assistant",
18+
sessionID: "session-1",
19+
parentID,
20+
time: { created: 1 },
21+
} as unknown as Message
22+
}
23+
24+
describe("findAssistantMessages", () => {
25+
test("normal ordering: assistant after user in array → found via forward scan", () => {
26+
const messages = [user("u1"), assistant("a1", "u1")]
27+
const result = findAssistantMessages(messages, 0, "u1")
28+
expect(result).toHaveLength(1)
29+
expect(result[0].id).toBe("a1")
30+
})
31+
32+
test("clock skew: assistant before user in array → found via backward scan", () => {
33+
// When client clock is ahead, user ID sorts after assistant ID,
34+
// so assistant appears earlier in the ID-sorted message array
35+
const messages = [assistant("a1", "u1"), user("u1")]
36+
const result = findAssistantMessages(messages, 1, "u1")
37+
expect(result).toHaveLength(1)
38+
expect(result[0].id).toBe("a1")
39+
})
40+
41+
test("no assistant messages → returns empty array", () => {
42+
const messages = [user("u1"), user("u2")]
43+
const result = findAssistantMessages(messages, 0, "u1")
44+
expect(result).toHaveLength(0)
45+
})
46+
47+
test("multiple assistant messages with matching parentID → all found", () => {
48+
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
49+
const result = findAssistantMessages(messages, 0, "u1")
50+
expect(result).toHaveLength(2)
51+
expect(result[0].id).toBe("a1")
52+
expect(result[1].id).toBe("a2")
53+
})
54+
55+
test("does not return assistant messages with different parentID", () => {
56+
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
57+
const result = findAssistantMessages(messages, 0, "u1")
58+
expect(result).toHaveLength(1)
59+
expect(result[0].id).toBe("a1")
60+
})
61+
62+
test("stops forward scan at next user message", () => {
63+
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
64+
const result = findAssistantMessages(messages, 0, "u1")
65+
expect(result).toHaveLength(1)
66+
expect(result[0].id).toBe("a1")
67+
})
68+
69+
test("stops backward scan at previous user message", () => {
70+
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
71+
const result = findAssistantMessages(messages, 3, "u1")
72+
expect(result).toHaveLength(1)
73+
expect(result[0].id).toBe("a1")
74+
})
75+
76+
test("invalid index returns empty array", () => {
77+
const messages = [user("u1")]
78+
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
79+
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
80+
})
81+
})

packages/opencode/src/session/prompt.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,12 +1342,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
13421342
const hasToolCalls =
13431343
lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
13441344

1345-
if (
1346-
lastAssistant?.finish &&
1347-
!["tool-calls"].includes(lastAssistant.finish) &&
1348-
!hasToolCalls &&
1349-
lastUser.id < lastAssistant.id
1350-
) {
1345+
if (!hasToolCalls && shouldExitLoop(lastUser, lastAssistant)) {
13511346
yield* slog.info("exiting loop")
13521347
break
13531348
}
@@ -1835,6 +1830,15 @@ export function createStructuredOutputTool(input: {
18351830
},
18361831
})
18371832
}
1833+
1834+
/** @internal Exported for testing */
1835+
export function shouldExitLoop(lastUser: MessageV2.User | undefined, lastAssistant: MessageV2.Assistant | undefined) {
1836+
if (!lastUser) return false
1837+
if (!lastAssistant?.finish) return false
1838+
if (["tool-calls", "unknown"].includes(lastAssistant.finish)) return false
1839+
return lastAssistant.parentID === lastUser.id
1840+
}
1841+
18381842
const bashRegex = /!`([^`]+)`/g
18391843
// Match [Image N] as single token, quoted strings, or non-space sequences
18401844
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, test } from "bun:test"
2+
import type { MessageV2 } from "../../src/session/message-v2"
3+
import { SessionPrompt } from "../../src/session/prompt"
4+
5+
function makeUser(id: string): MessageV2.User {
6+
return {
7+
id,
8+
role: "user",
9+
sessionID: "session-1",
10+
time: { created: Date.now() },
11+
agent: "default",
12+
model: { providerID: "openai", modelID: "gpt-4" },
13+
} as MessageV2.User
14+
}
15+
16+
function makeAssistant(
17+
id: string,
18+
parentID: string,
19+
finish?: string,
20+
): MessageV2.Assistant {
21+
return {
22+
id,
23+
role: "assistant",
24+
sessionID: "session-1",
25+
parentID,
26+
mode: "default",
27+
agent: "default",
28+
path: { cwd: "/tmp", root: "/tmp" },
29+
cost: 0,
30+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
31+
modelID: "gpt-4",
32+
providerID: "openai",
33+
time: { created: Date.now() },
34+
finish,
35+
} as MessageV2.Assistant
36+
}
37+
38+
describe("shouldExitLoop", () => {
39+
test("normal case: user ID < assistant ID, parentID matches, finish=end_turn → exits", () => {
40+
const user = makeUser("01AAA")
41+
const assistant = makeAssistant("01BBB", "01AAA", "end_turn")
42+
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true)
43+
})
44+
45+
test("clock skew: user ID > assistant ID, parentID matches, finish=stop → exits", () => {
46+
// Simulates client clock ahead: user message ID sorts AFTER the assistant ID
47+
const user = makeUser("01ZZZ")
48+
const assistant = makeAssistant("01AAA", "01ZZZ", "stop")
49+
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true)
50+
})
51+
52+
test("unfinished assistant: finish=tool-calls → does NOT exit", () => {
53+
const user = makeUser("01AAA")
54+
const assistant = makeAssistant("01BBB", "01AAA", "tool-calls")
55+
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
56+
})
57+
58+
test("unfinished assistant: finish=unknown → does NOT exit", () => {
59+
const user = makeUser("01AAA")
60+
const assistant = makeAssistant("01BBB", "01AAA", "unknown")
61+
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
62+
})
63+
64+
test("no assistant yet → does NOT exit", () => {
65+
const user = makeUser("01AAA")
66+
expect(SessionPrompt.shouldExitLoop(user, undefined)).toBe(false)
67+
})
68+
69+
test("assistant has no finish → does NOT exit", () => {
70+
const user = makeUser("01AAA")
71+
const assistant = makeAssistant("01BBB", "01AAA", undefined)
72+
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
73+
})
74+
75+
test("parentID mismatch → does NOT exit", () => {
76+
const user = makeUser("01AAA")
77+
const assistant = makeAssistant("01BBB", "01OTHER", "end_turn")
78+
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
79+
})
80+
81+
test("no user message → does NOT exit", () => {
82+
const assistant = makeAssistant("01BBB", "01AAA", "end_turn")
83+
expect(SessionPrompt.shouldExitLoop(undefined, assistant)).toBe(false)
84+
})
85+
})
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { AssistantMessage, Message as MessageType } from "@opencode-ai/sdk/v2/client"
2+
3+
/**
4+
* Find assistant messages that are replies to a given user message.
5+
*
6+
* Scans forward from the user message index first, then falls back to scanning
7+
* backward. The backward scan handles clock skew where assistant messages
8+
* (generated server-side) sort before the user message (generated client-side
9+
* with an ahead clock) in the ID-sorted array.
10+
*/
11+
export function findAssistantMessages(
12+
messages: MessageType[],
13+
userIndex: number,
14+
userID: string,
15+
): AssistantMessage[] {
16+
if (userIndex < 0 || userIndex >= messages.length) return []
17+
18+
const result: AssistantMessage[] = []
19+
20+
// Scan forward from user message
21+
for (let i = userIndex + 1; i < messages.length; i++) {
22+
const item = messages[i]
23+
if (!item) continue
24+
if (item.role === "user") break
25+
if (item.role === "assistant" && item.parentID === userID) result.push(item as AssistantMessage)
26+
}
27+
28+
// Scan backward to find assistant messages that sort before the user
29+
// message due to clock skew between client and server
30+
if (result.length === 0) {
31+
for (let i = userIndex - 1; i >= 0; i--) {
32+
const item = messages[i]
33+
if (!item) continue
34+
if (item.role === "user") break
35+
if (item.role === "assistant" && item.parentID === userID) result.push(item as AssistantMessage)
36+
}
37+
}
38+
39+
return result
40+
}

packages/ui/src/components/session-turn.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } fr
1414
import { createStore } from "solid-js/store"
1515
import { Dynamic } from "solid-js/web"
1616
import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part"
17+
import { findAssistantMessages } from "./find-assistant-messages"
1718
import { Card } from "./card"
1819
import { Accordion } from "./accordion"
1920
import { StickyAccordionHeader } from "./sticky-accordion-header"
@@ -267,15 +268,9 @@ export function SessionTurn(
267268
if (!msg) return emptyAssistant
268269

269270
const messages = allMessages() ?? emptyMessages
270-
if (messageIndex() < 0) return emptyAssistant
271-
272-
const result: AssistantMessage[] = []
273-
for (let i = 0; i < messages.length; i++) {
274-
const item = messages[i]
275-
if (!item) continue
276-
if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage)
277-
}
278-
return result
271+
const index = messageIndex()
272+
if (index < 0) return emptyAssistant
273+
return findAssistantMessages(messages, index, msg.id)
279274
},
280275
emptyAssistant,
281276
{ equals: same },

0 commit comments

Comments
 (0)