Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
21 changes: 12 additions & 9 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,29 +602,32 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses

// filterCompacted reorders messages for model consumption
// ([compaction-user, summary, ...retained tail..., continue-user]), so array
// position is not chronological. Derive each binding by max id (MessageID
// is monotonic via MessageID.ascending) so a pre-compaction overflowing tail
// assistant doesn't get mistaken for the most recent turn. tasks are
// compaction/subtask parts attached to user messages newer than the latest
// finished assistant — i.e. unprocessed work.
// position is not chronological. IDs are only a deterministic tie-breaker
// because imported messages do not necessarily have monotonic IDs.
export function latest(msgs: WithParts[]) {
let user: User | undefined
let assistant: Assistant | undefined
let finished: Assistant | undefined
for (const msg of msgs) {
const info = msg.info
if (info.role === "user" && (!user || info.id > user.id)) user = info
if (info.role === "assistant" && (!assistant || info.id > assistant.id)) assistant = info
if (info.role === "assistant" && info.finish && (!finished || info.id > finished.id)) finished = info
if (info.role === "user" && isAfter(info, user)) user = info
if (info.role === "assistant" && isAfter(info, assistant)) assistant = info
if (info.role === "assistant" && info.finish && isAfter(info, finished)) finished = info
}
const tasks = msgs.flatMap((m) =>
finished && m.info.id <= finished.id
finished && !isAfter(m.info, finished)
? []
: m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"),
)
return { user, assistant, finished, tasks }
}

function isAfter(info: Info, other?: Info) {
if (!other) return true
if (info.time.created !== other.time.created) return info.time.created > other.time.created
return info.id > other.id
}

export function fromError(
e: unknown,
ctx: { providerID: ProviderV2.ID; aborted?: boolean },
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,7 +1112,7 @@ const layer = Layer.effect(
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastUser.id < lastAssistant.id
lastAssistant.parentID === lastUser.id

@cubic-dev-ai cubic-dev-ai Bot Aug 15, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The new exit condition lastAssistant.parentID === lastUser.id never holds when the final completed assistant message has no resolvable parentID, which can happen for the restored/imported sessions this PR targets (session fork/import in session.ts only preserves parentID when the parent id resolves in idMap). In that case the loop does not recognize the completed turn and issues an unwanted extra LLM call before self-healing. Consider treating a missing/unmatched parentID (e.g. no later user than the assistant's parent by time) as a completed turn so restored legacy sessions exit without a redundant request.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/session/prompt.ts, line 1115:

<comment>The new exit condition `lastAssistant.parentID === lastUser.id` never holds when the final completed assistant message has no resolvable `parentID`, which can happen for the restored/imported sessions this PR targets (session fork/import in session.ts only preserves parentID when the parent id resolves in `idMap`). In that case the loop does not recognize the completed turn and issues an unwanted extra LLM call before self-healing. Consider treating a missing/unmatched parentID (e.g. no later user than the assistant's parent by time) as a completed turn so restored legacy sessions exit without a redundant request.</comment>

<file context>
@@ -1112,7 +1112,7 @@ const layer = Layer.effect(
             !["tool-calls"].includes(lastAssistant.finish) &&
             !hasToolCalls &&
-            lastUser.id < lastAssistant.id
+            lastAssistant.parentID === lastUser.id
           ) {
             const orphan = lastAssistantMsg?.parts.find(
</file context>
Fix with cubic

) {
const orphan = lastAssistantMsg?.parts.find(
(part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
Expand Down
67 changes: 67 additions & 0 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1622,6 +1622,44 @@ describe("session.message-v2.latest", () => {
] as SessionV1.Part[],
}

test("selects latest messages by creation time when IDs are nonmonotonic", () => {
const oldUser = { ...userInfo("msg_z_user"), time: { created: 100 } }
const newUser = { ...userInfo("msg_a_user"), time: { created: 200 } }
const oldAssistant = {
...assistantInfo("msg_z_assistant", oldUser.id),
time: { created: 300 },
finish: "stop",
} as SessionV1.Assistant
const newAssistant = {
...assistantInfo("msg_a_assistant", newUser.id),
time: { created: 400 },
finish: "stop",
} as SessionV1.Assistant

const state = MessageV2.latest([
{ info: newAssistant, parts: [] },
{ info: oldUser, parts: [] },
{ info: oldAssistant, parts: [] },
{ info: newUser, parts: [] },
])

expect(state.user?.id).toBe(newUser.id)
expect(state.assistant?.id).toBe(newAssistant.id)
expect(state.finished?.id).toBe(newAssistant.id)
})

test("uses ID as a deterministic tie-breaker for equal creation times", () => {
const lower = { ...userInfo("msg_a_user"), time: { created: 100 } }
const higher = { ...userInfo("msg_z_user"), time: { created: 100 } }

const state = MessageV2.latest([
{ info: higher, parts: [] },
{ info: lower, parts: [] },
])

expect(state.user?.id).toBe(higher.id)
})

// Regression for double auto-compaction. The reorder in filterCompacted
// (#27145) returns [compaction-user, summary, ...tail..., continue-user],
// so picking lastFinished by array position landed on the pre-compaction
Expand Down Expand Up @@ -1670,4 +1708,33 @@ describe("session.message-v2.latest", () => {
expect(state.tasks).toHaveLength(1)
expect(state.tasks[0]).toMatchObject({ type: "compaction", auto: true })
})

test("selects compaction and subtask work after the finished boundary by creation time", () => {
const finished = {
...assistantInfo("msg_z_finished", "msg_parent"),
time: { created: 200 },
finish: "stop",
} as SessionV1.Assistant
const oldTask: SessionV1.WithParts = {
info: { ...userInfo("msg_z_old"), time: { created: 100 } },
parts: [{ ...basePart("msg_z_old", "old"), type: "compaction", auto: true }] as SessionV1.Part[],
}
const newTask: SessionV1.WithParts = {
info: { ...userInfo("msg_a_new"), time: { created: 300 } },
parts: [
{
...basePart("msg_a_new", "new"),
type: "subtask",
prompt: "inspect",
description: "inspect ordering",
agent: "general",
},
] as SessionV1.Part[],
}

const state = MessageV2.latest([newTask, { info: finished, parts: [] }, oldTask])

expect(state.tasks).toHaveLength(1)
expect(state.tasks[0]).toMatchObject({ type: "subtask", prompt: "inspect" })
})
})
40 changes: 40 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,46 @@ noLLMServer.instance(
{ config: cfg },
)

noLLMServer.instance(
"loop exits for a completed parent turn with nonmonotonic message IDs",
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
const userID = MessageID.make("msg_z_user")
const assistantID = MessageID.make("msg_a_assistant")
yield* sessions.updateMessage({
id: userID,
role: "user",
sessionID: chat.id,
agent: "build",
model: ref,
time: { created: 100 },
})
yield* sessions.updateMessage({
id: assistantID,
role: "assistant",
parentID: userID,
sessionID: chat.id,
mode: "build",
agent: "build",
cost: 0,
path: { cwd: "/tmp", root: "/tmp" },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ref.modelID,
providerID: ref.providerID,
time: { created: 200, completed: 201 },
finish: "stop",
})

const result = yield* prompt.loop({ sessionID: chat.id })

expect(result.info.id).toBe(assistantID)

@cubic-dev-ai cubic-dev-ai Bot Aug 15, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This test does not discriminate the fix: with a single user message and a single assistant message, MessageV2.latest/isAfter returns the same result whether ordered by ID or by time.created, so the test would pass on the pre-fix code that sorts only by ID. To actually guard the rollover regression described in the PR, add a second (newer-by-time) user message whose ID sorts below the complete assistant, matching the cross-rollover layout where ID order and time order diverge.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/test/session/prompt.test.ts, line 498:

<comment>This test does not discriminate the fix: with a single user message and a single assistant message, `MessageV2.latest`/`isAfter` returns the same result whether ordered by ID or by `time.created`, so the test would pass on the pre-fix code that sorts only by ID. To actually guard the rollover regression described in the PR, add a second (newer-by-time) user message whose ID sorts below the complete assistant, matching the cross-rollover layout where ID order and time order diverge.</comment>

<file context>
@@ -460,6 +460,46 @@ noLLMServer.instance(
+
+      const result = yield* prompt.loop({ sessionID: chat.id })
+
+      expect(result.info.id).toBe(assistantID)
+    }),
+  { config: cfg },
</file context>
Fix with cubic

}),
{ config: cfg },
)

it.instance("loop exits without an LLM request for interrupted orphan tool calls", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
Expand Down
Loading