mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 19:33:26 +00:00
fix(opencode): order legacy message loop by time (#40990)
Co-authored-by: Dax <mail@thdxr.com>
This commit is contained in:
parent
a54a693af2
commit
db581e47a3
4 changed files with 120 additions and 10 deletions
|
|
@ -577,29 +577,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 },
|
||||
|
|
|
|||
|
|
@ -1112,7 +1112,7 @@ const layer = Layer.effect(
|
|||
lastAssistant?.finish &&
|
||||
!["tool-calls"].includes(lastAssistant.finish) &&
|
||||
!hasToolCalls &&
|
||||
lastUser.id < lastAssistant.id
|
||||
lastAssistant.parentID === lastUser.id
|
||||
) {
|
||||
const orphan = lastAssistantMsg?.parts.find(
|
||||
(part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
|
||||
|
|
|
|||
|
|
@ -1611,6 +1611,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
|
||||
|
|
@ -1659,4 +1697,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" })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
{ config: cfg },
|
||||
)
|
||||
|
||||
it.instance("loop exits without an LLM request for interrupted orphan tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const { llm } = yield* useServerConfig(providerCfg)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue