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
6 changes: 4 additions & 2 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,10 +412,12 @@ const layer = Layer.effect(
return "stop"
}

if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
// A replay re-asks the overflowing message, so nothing before this marker may survive next to it.
const tailStartID = replay ? input.parentID : selected.tail_start_id
if (compactionPart && tailStartID && compactionPart.tail_start_id !== tailStartID) {
yield* session.updatePart({
...compactionPart,
tail_start_id: selected.tail_start_id,
tail_start_id: tailStartID,
})
}

Expand Down
32 changes: 24 additions & 8 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ import { LLMEvent } from "@opencode-ai/llm"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false

const MAX_OVERFLOW_COMPACTIONS_PER_TURN = 1

const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info)
const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part)
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
Expand Down Expand Up @@ -1087,6 +1089,7 @@ const layer = Layer.effect(
const ctx = yield* InstanceState.context
let structured: unknown
let step = 0
let overflowCompactions = 0
const turnStart = Date.now()
let firstTokenAt: number | undefined
let firstTokenReqStart: number | undefined
Expand Down Expand Up @@ -1376,15 +1379,28 @@ const layer = Layer.effect(
}

if (result === "stop") return "break" as const
if (result === "compact") {
yield* compaction.create({
sessionID,
agent: lastUser.agent,
model: lastUser.model,
auto: true,
overflow: !handle.message.finish,
})
if (result === "continue") {
overflowCompactions = 0
return "continue" as const
}
const overflow = !handle.message.finish
// Compaction already replayed this message once; overflowing again means it can never fit.
if (overflow && overflowCompactions >= MAX_OVERFLOW_COMPACTIONS_PER_TURN) {
handle.message.error = new SessionV1.ContextOverflowError({
message: "Message exceeds the model context limit even after compaction",
}).toObject()
handle.message.finish = "error"
yield* sessions.updateMessage(handle.message)
return "break" as const
}
if (overflow) overflowCompactions++
yield* compaction.create({
sessionID,
agent: lastUser.agent,
model: lastUser.model,
auto: true,
overflow,
})
return "continue" as const
}).pipe(
Effect.ensuring(instruction.clear(handle.message.id)),
Expand Down
39 changes: 39 additions & 0 deletions packages/opencode/test/session/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,45 @@ describe("session.compaction.process", () => {
}),
)

it.instance(
"overflow replay drops the overflowing message from the retained window",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
const big = "x".repeat(600_000)
const first = yield* createUserMessage(session.id, big)
yield* createAssistantMessage(session.id, first.id, "/")
const resent = yield* createUserMessage(session.id, big)
yield* SessionCompaction.use.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: true,
overflow: true,
})
const msgs = yield* MessageV2.filterCompactedEffect(session.id)
const marker = msgs.at(-1)!

const result = yield* SessionCompaction.use.process({
parentID: marker.info.id,
messages: msgs,
sessionID: session.id,
auto: true,
overflow: true,
})
expect(result).toBe("continue")
// The fake processor never stamps finish; filterCompacted only honours a finished summary.
const all = yield* ssn.messages({ sessionID: session.id })
const summary = all.find((m) => m.info.role === "assistant" && m.info.parentID === marker.info.id)!
yield* ssn.updateMessage({ ...(summary.info as SessionV1.Assistant), finish: "stop" })

const context = yield* MessageV2.filterCompactedEffect(session.id)
expect(context.map((m) => m.info.role)).toEqual(["user", "assistant", "user"])
expect(context.some((m) => m.info.id === resent.id)).toBe(false)
expect(context.at(-1)?.parts.some((part) => part.type === "text" && part.text === big)).toBe(true)
}),
)

it.instance(
"falls back to overflow guidance when no replayable turn exists",
Effect.gen(function* () {
Expand Down
40 changes: 36 additions & 4 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,36 @@ it.instance("loop stops provider overflow instead of auto-compacting when disabl
}),
)

it.instance("loop stops after one overflow compaction when the replay still overflows", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })

yield* llm.error(413, { error: { message: "prompt is too long" } })
yield* llm.text("## Summary")
yield* llm.error(413, { error: { message: "prompt is too long" } })
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})

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

expect(yield* llm.hits).toHaveLength(3)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.error?.name).toBe("ContextOverflowError")
expect(result.info.finish).toBe("error")
}
expect(messages.filter((message) => message.parts.some((part) => part.type === "compaction"))).toHaveLength(1)
}),
)

noLLMServer.instance.skip(
"prompt emits v2 prompted and synthetic events (v2 projector disabled)",
() =>
Expand Down Expand Up @@ -836,10 +866,12 @@ it.instance("turn.done logs prep_ms from the first request even when ttft captur
const capture = Logger.make((options) => {
captured.push(options.message)
})
yield* prompt.loop({ sessionID: session.id }).pipe(
Effect.provide(Logger.layer([capture], { mergeWithExisting: true })),
Effect.provide(Layer.succeed(References.MinimumLogLevel, "Debug")),
)
yield* prompt
.loop({ sessionID: session.id })
.pipe(
Effect.provide(Logger.layer([capture], { mergeWithExisting: true })),
Effect.provide(Layer.succeed(References.MinimumLogLevel, "Debug")),
)

const message = captured.find((item) => Array.isArray(item) && item[0] === "turn.done")
expect(message).toBeDefined()
Expand Down
Loading