Skip to content

Commit a7c6194

Browse files
committed
fix: apply upstream PRs anomalyco#29949 (cache stability) and anomalyco#30072 (O(N²)→O(N) streaming)
anomalyco#29949 — fix(session): move env block to tail of system prompt for cache stability Reorders system prompt so volatile env fields (cwd, date, model id) sit at the tail, enabling 95-98% prefix cache hit rate across fresh sessions. Adds \n prefix to env block for canonical byte seam. anomalyco#30072 — fix(opencode): O(N²)→O(N) text/reasoning delta accumulation Replaces string concatenation in streaming deltas with chunked array + lazy getter pattern, eliminating quadratic memmove in long thinking-mode sessions (per-step drops from 61s to 21s on SWE-bench).
1 parent b2a0635 commit a7c6194

4 files changed

Lines changed: 78 additions & 5 deletions

File tree

packages/opencode/src/session/processor.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,41 @@ import { Usage, type LLMEvent } from "@opencode-ai/llm"
3232
const DOOM_LOOP_THRESHOLD = 3
3333
const log = Log.create({ service: "session.processor" })
3434

35+
/**
36+
* Replace `obj.text` with a lazy getter backed by an array of chunks so
37+
* streaming text/reasoning deltas can be appended in O(1) instead of
38+
* accumulated via `text += delta` (which is O(N²) once anything reads
39+
* `.text` between writes — V8/JSC flatten the rope on every read and then
40+
* re-copy on every subsequent `+=`).
41+
*
42+
* `_chunks` is non-enumerable so it does NOT leak through
43+
* `JSON.stringify` / `structuredClone` / `{...obj}` to consumers. The
44+
* `.text` getter materializes on first read, caches the joined string back
45+
* into `_chunks` (so subsequent reads stay O(1)), and the setter resets
46+
* `_chunks = [v]` so `obj.text = "..."` reassignments still work as
47+
* expected. Same shape as the fix landed in vercel/ai for
48+
* `processUIMessageStream` / `DefaultStreamTextResult`.
49+
*/
50+
function installChunkedText(obj: { text: string }): void {
51+
Object.defineProperty(obj, "_chunks", {
52+
value: [obj.text || ""],
53+
writable: true,
54+
enumerable: false,
55+
configurable: true,
56+
})
57+
Object.defineProperty(obj, "text", {
58+
get(): string {
59+
const c = (this as any)._chunks as string[]
60+
return c.length === 1 ? c[0] : ((this as any)._chunks = [c.join("")])[0]
61+
},
62+
set(v: string) {
63+
;(this as any)._chunks = [v]
64+
},
65+
enumerable: true,
66+
configurable: true,
67+
})
68+
}
69+
3570
export type Result = "compact" | "stop" | "continue"
3671

3772
export interface Handle {
@@ -323,13 +358,20 @@ export const layer = Layer.effect(
323358
time: { start: Date.now() },
324359
metadata: value.providerMetadata,
325360
}
361+
installChunkedText(ctx.reasoningMap[value.id])
326362
yield* session.updatePart(ctx.reasoningMap[value.id])
327363
return
328364

329365
case "reasoning-delta":
330366
// Match dev: silently drop orphan deltas (no preceding reasoning-start).
331367
if (!(value.id in ctx.reasoningMap)) return
332-
ctx.reasoningMap[value.id].text += value.text
368+
// O(N) chunk-append instead of O(N²) string concat. For
369+
// thinking-mode models emitting 1500+ reasoning tokens per turn
370+
// over 80+ turns, `text += value.text` here pegs JSC's GC with
371+
// hundreds of MB of cumulative memmove work. The lazy getter
372+
// installed at reasoning-start joins chunks on read; here we
373+
// just push.
374+
;(ctx.reasoningMap[value.id] as any)._chunks.push(value.text)
333375
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
334376
yield* session.updatePartDelta({
335377
sessionID: ctx.reasoningMap[value.id].sessionID,
@@ -635,12 +677,15 @@ export const layer = Layer.effect(
635677
time: { start: Date.now() },
636678
metadata: value.providerMetadata,
637679
}
680+
installChunkedText(ctx.currentText)
638681
yield* session.updatePart(ctx.currentText)
639682
return
640683

641684
case "text-delta":
642685
if (!ctx.currentText) return
643-
ctx.currentText.text += value.text
686+
// Same O(N²) fix as reasoning-delta above. The lazy getter
687+
// installed at text-start joins chunks on read; here we just push.
688+
;(ctx.currentText as any)._chunks.push(value.text)
644689
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
645690
yield* session.updatePartDelta({
646691
sessionID: ctx.currentText.sessionID,

packages/opencode/src/session/prompt.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1438,7 +1438,12 @@ export const layer = Layer.effect(
14381438
instruction.system().pipe(Effect.orDie),
14391439
MessageV2.toModelMessagesEffect(msgs, model),
14401440
])
1441-
const system = [...env, ...instructions, ...(skills ? [skills] : [])]
1441+
// Cache-friendly order: place stable instructions/skills first so byte 0 of
1442+
// the assembled system payload is invariant across sessions, days, and users.
1443+
// The env block (which contains a per-session datetime, per-project cwd, and
1444+
// per-agent model id) goes at the tail so it cannot defeat prefix-cache hits
1445+
// on the upstream model. See anomalyco/opencode#20110, anomalyco/opencode#5224.
1446+
const system = [...instructions, ...(skills ? [skills] : []), ...env]
14421447
const format = lastUser.format ?? { type: "text" as const }
14431448
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
14441449
const result = yield* handle.process({

packages/opencode/src/session/system.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const layer = Layer.effect(
4949
const ctx = yield* InstanceState.context
5050
return [
5151
[
52-
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
52+
`\nYou are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
5353
`Here is some useful information about the environment you are running in:`,
5454
`<env>`,
5555
` Working directory: ${ctx.directory}`,

packages/opencode/test/session/prompt.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NodeFileSystem } from "@effect/platform-node"
22
import { FetchHttpClient } from "effect/unstable/http"
3-
import { expect } from "bun:test"
3+
import { readFileSync } from "fs"
4+
import { expect, test } from "bun:test"
45
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
56
import path from "path"
67
import { fileURLToPath, pathToFileURL } from "url"
@@ -2343,3 +2344,25 @@ noLLMServer.instance(
23432344
}),
23442345
30_000,
23452346
)
2347+
2348+
test("session/prompt: source assembles system array with env at tail for cache stability", () => {
2349+
const source = readFileSync(
2350+
path.resolve(__dirname, "../../src/session/prompt.ts"),
2351+
"utf8",
2352+
)
2353+
const match = source.match(
2354+
/const system = \[\.\.\.(\w+),\s*\.\.\.\(skills \? \[skills\] : \[\]\),\s*\.\.\.(\w+)\]/,
2355+
)
2356+
expect(match).not.toBeNull()
2357+
expect(match![1]).toBe("instructions")
2358+
expect(match![2]).toBe("env")
2359+
})
2360+
2361+
test("session/prompt: env block leads with \\n so the skills/env seam is a blank line", () => {
2362+
const source = readFileSync(
2363+
path.resolve(__dirname, "../../src/session/system.ts"),
2364+
"utf8",
2365+
)
2366+
const match = source.match(/`\\nYou are powered by the model/)
2367+
expect(match).not.toBeNull()
2368+
})

0 commit comments

Comments
 (0)