Skip to content
Open
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: 17 additions & 4 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,18 +582,31 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses
// 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.
// Order by wall-clock first, id second. Ids minted by Identifier.ascending()
// embed a timestamp and so sort chronologically on their own, but sessions can
// also arrive via `opencode import` from third-party writers whose ids carry no
// ordering. A single such id sorting above the live conversation used to make
// the newest message unreachable here, so the run loop's exit test could never
// pass and opencode re-requested until the provider rejected the
// assistant-terminated conversation. `time.created` is authoritative; the id
// stays as the tiebreaker for messages minted within the same millisecond.
function isNewer(a: { id: string; time: { created: number } }, b: { id: string; time: { created: number } }) {
if (a.time.created !== b.time.created) return a.time.created > b.time.created
return a.id > b.id
}

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" && (!user || isNewer(info, user))) user = info
if (info.role === "assistant" && (!assistant || isNewer(info, assistant))) assistant = info
if (info.role === "assistant" && info.finish && (!finished || isNewer(info, finished))) finished = info
}
const tasks = msgs.flatMap((m) =>
finished && m.info.id <= finished.id
finished && !isNewer(m.info, finished)
? []
: m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"),
)
Expand Down
13 changes: 12 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1108,11 +1108,22 @@ const layer = Layer.effect(
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
) ?? false

// Compare by wall-clock, not by raw id. Ids only sort chronologically
// when minted by Identifier.ascending(), which is not guaranteed for
// sessions brought in through `opencode import`; an unordered id here
// used to keep this test permanently false, so the loop never exited.
// The id remains the tiebreaker within a single millisecond.
const assistantFollowsUser =
!!lastAssistant &&
(lastUser.time.created !== lastAssistant.time.created
? lastUser.time.created < lastAssistant.time.created
: lastUser.id < lastAssistant.id)

if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastUser.id < lastAssistant.id
assistantFollowsUser
) {
const orphan = lastAssistantMsg?.parts.find(
(part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
Expand Down
78 changes: 78 additions & 0 deletions packages/opencode/test/message-latest-ordering.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test"

import { MessageV2 } from "../src/session/message-v2"

/**
* `latest()` used to pick the newest user/assistant message by comparing ids as
* plain strings. That holds for ids minted by Identifier.ascending(), which
* embed a timestamp, but not for sessions written by anything else — e.g. a
* third-party importer emitting uuids. A single imported message whose id
* happens to sort high then masquerades as the newest message, the run loop's
* exit test (`lastUser.id < lastAssistant.id`) never passes, and opencode
* re-requests until the provider rejects the assistant-terminated conversation.
*/
function msg(id: string, role: "user" | "assistant", created: number, finish?: string) {
return {
info: {
id,
sessionID: "ses_test",
role,
time: { created },
...(role === "assistant" ? { finish } : {}),
},
parts: [],
} as unknown as MessageV2.WithParts
}

describe("MessageV2.latest ordering", () => {
test("uses chronology, not raw id sort, to find the newest messages", () => {
// An imported history whose ids do not sort chronologically: the FIRST
// message carries the highest-sorting id.
const msgs = [
msg("msg_ffffffffffffffffffffffff", "assistant", 1_000, "stop"), // oldest, id sorts highest
msg("msg_0000000000000000000000aa", "user", 2_000),
msg("msg_0000000000000000000000bb", "assistant", 3_000, "stop"), // newest in time
]

const { user, assistant } = MessageV2.latest(msgs)

expect(user?.id).toBe("msg_0000000000000000000000aa")
// Raw id sort would return the 1_000ms message here.
expect(assistant?.id).toBe("msg_0000000000000000000000bb")
expect(assistant?.time.created).toBe(3_000)
})

test("the newest assistant follows the newest user, so the run loop can exit", () => {
const msgs = [
msg("msg_ffffffffffffffffffffffff", "assistant", 1_000, "stop"),
msg("msg_0000000000000000000000aa", "user", 2_000),
msg("msg_0000000000000000000000bb", "assistant", 3_000, "stop"),
]

const { user, assistant } = MessageV2.latest(msgs)

// What runLoop asserts before breaking out of the loop.
expect(user!.time.created).toBeLessThan(assistant!.time.created)
})

test("still orders correctly for natively minted, time-sortable ids", () => {
const msgs = [
msg("msg_f90250c84002aaaaaaaaaaaaaa", "user", 1_000),
msg("msg_f90253552001bbbbbbbbbbbbbb", "assistant", 2_000, "stop"),
]

const { user, assistant } = MessageV2.latest(msgs)

expect(user?.id).toBe("msg_f90250c84002aaaaaaaaaaaaaa")
expect(assistant?.id).toBe("msg_f90253552001bbbbbbbbbbbbbb")
})

test("ties on timestamp fall back to id order", () => {
const msgs = [
msg("msg_aaa", "assistant", 5_000, "stop"),
msg("msg_bbb", "assistant", 5_000, "stop"),
]

expect(MessageV2.latest(msgs).assistant?.id).toBe("msg_bbb")
})
})
Loading