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
23 changes: 17 additions & 6 deletions packages/core/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const settings = (documents: readonly Config.Entry[]) => {
)
}

const select = (
export const select = (
entries: readonly Entry[],
tokens: number,
): { readonly head: string; readonly recent: string } | undefined => {
Expand All @@ -140,7 +140,12 @@ const select = (
.filter(Boolean)
if (conversation.length === 0) return
let total = 0
let split = conversation.length
// head ends at headEnd (exclusive); recent starts at recentStart. They differ
// only when the budget boundary lands mid-message: that message is split into
// splitPrefix (head) and splitSuffix (recent), so the full message must NOT
// also appear in head's slice — head ends at `index`, recent starts at `index + 1`.
let headEnd = conversation.length
let recentStart = conversation.length
let splitPrefix = ""
let splitSuffix = ""
for (let index = conversation.length - 1; index >= 0; index--) {
Expand All @@ -150,16 +155,22 @@ const select = (
if (remaining > 0) {
splitPrefix = conversation[index].slice(0, -remaining)
splitSuffix = conversation[index].slice(-remaining)
split = index + 1
headEnd = index
recentStart = index + 1
} else {
// No room even for a suffix: the boundary message goes entirely to head.
headEnd = index + 1
recentStart = index + 1
}
break
}
total = next
split = index
headEnd = index
recentStart = index
}
return {
head: [...conversation.slice(0, split), splitPrefix].filter(Boolean).join("\n\n"),
recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"),
head: [...conversation.slice(0, headEnd), splitPrefix].filter(Boolean).join("\n\n"),
recent: [splitSuffix, ...conversation.slice(recentStart)].filter(Boolean).join("\n\n"),
}
}

Expand Down
43 changes: 43 additions & 0 deletions packages/core/test/session-compaction.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test"
import { DateTime } from "effect"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionMessage } from "@opencode-ai/core/session/message"

test("compaction describes tool media without embedding base64", () => {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
Expand All @@ -16,3 +18,44 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]")
expect(serialized).not.toContain(base64)
})

const created = DateTime.makeUnsafe(0)
const userEntry = (seq: number, text: string) => ({
seq,
message: new SessionMessage.User({
id: SessionMessage.ID.make(`msg_${seq}`),
type: "user",
text,
time: { created },
}),
})

test("select does not duplicate the boundary message into head", () => {
// serialize() renders a user message as `[User]: <text>`; Token.estimate is
// round(length / 4). With tokens=5: the small message (estimate 3) fits, then
// the big message overflows and is split mid-message. The boundary message
// must appear in head ONLY as the truncated prefix — never in full.
const big = "A".repeat(40)
const entries = [userEntry(0, big), userEntry(1, "BBBB")]

const result = SessionCompaction.select(entries, 5)

expect(result).toBeDefined()
// head is just the prefix slice, not the full boundary message + its prefix.
expect(result!.head).toBe(`[User]: ${"A".repeat(32)}`)
expect(result!.recent).toBe(`${"A".repeat(8)}\n\n[User]: BBBB`)
// Regression guard for the duplication bug (head once contained the full
// 40-char message AND its 32-char prefix copy):
expect(result!.head).not.toContain("A".repeat(40))
expect(result!.head.split("[User]:").length - 1).toBe(1)
})

test("select keeps everything in recent when the whole conversation fits", () => {
const entries = [userEntry(0, "first"), userEntry(1, "second")]

const result = SessionCompaction.select(entries, 1000)

expect(result).toBeDefined()
expect(result!.head).toBe("")
expect(result!.recent).toBe("[User]: first\n\n[User]: second")
})
25 changes: 24 additions & 1 deletion packages/llm/src/protocols/bedrock-converse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,9 +378,25 @@ const lowerSystem = (
system: ReadonlyArray<LLMRequest["system"][number]>,
): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))

const bedrockOptions = (request: LLMRequest) => request.providerOptions?.bedrock

const lowerThinking = Effect.fn("BedrockConverse.lowerThinking")(function* (request: LLMRequest) {
const thinking = bedrockOptions(request)?.thinking
if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined
const budget =
typeof thinking.budgetTokens === "number"
? thinking.budgetTokens
: typeof thinking.budget_tokens === "number"
? thinking.budget_tokens
: undefined
if (budget === undefined) return yield* ProviderShared.invalidRequest("Bedrock thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget }
})

const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation
const thinking = yield* lowerThinking(request)
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints()
Expand Down Expand Up @@ -414,7 +430,14 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
toolConfig,
// Converse's base inferenceConfig has no topK; Anthropic/Nova accept it
// as a model-specific field, so it goes through additionalModelRequestFields.
additionalModelRequestFields: generation?.topK === undefined ? undefined : { top_k: generation.topK },
// Extended thinking (Claude 3.7+ on Bedrock) is also passed here.
additionalModelRequestFields:
generation?.topK === undefined && thinking === undefined
? undefined
: {
...(generation?.topK !== undefined ? { top_k: generation.topK } : {}),
...(thinking !== undefined ? { thinking } : {}),
},
}
})

Expand Down
39 changes: 39 additions & 0 deletions packages/llm/test/provider/bedrock-converse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,45 @@ describe("Bedrock Converse route", () => {
}),
)

it.effect("passes thinking through additionalModelRequestFields", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.updateRequest(baseRequest, {
providerOptions: { bedrock: { thinking: { type: "enabled", budgetTokens: 4096 } } },
}),
)
expect(prepared.body.additionalModelRequestFields).toEqual({
thinking: { type: "enabled", budget_tokens: 4096 },
})
}),
)

it.effect("passes both topK and thinking through additionalModelRequestFields", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.updateRequest(baseRequest, {
generation: { maxTokens: 64, temperature: 0, topK: 40 },
providerOptions: { bedrock: { thinking: { type: "enabled", budgetTokens: 8192 } } },
}),
)
expect(prepared.body.additionalModelRequestFields).toEqual({
top_k: 40,
thinking: { type: "enabled", budget_tokens: 8192 },
})
}),
)

it.effect("rejects thinking without budgetTokens", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.updateRequest(baseRequest, {
providerOptions: { bedrock: { thinking: { type: "enabled" } } },
}),
).pipe(Effect.flip)
expect(error.message).toContain("Bedrock thinking provider option requires budgetTokens")
}),
)

it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
Expand Down
Loading