mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-26 15:55:45 +00:00
refactor(session): effectify SessionCompaction service (#19459)
This commit is contained in:
@@ -1,15 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { SessionCompaction } from "../../src/session/compaction"
|
||||
import { Token } from "../../src/util/token"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Session } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import type { Provider } from "../../src/provider/provider"
|
||||
import * as ProviderModule from "../../src/provider/provider"
|
||||
import * as SessionProcessorModule from "../../src/session/processor"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
const ref = {
|
||||
providerID: ProviderID.make("test"),
|
||||
modelID: ModelID.make("test-model"),
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
function createModel(opts: {
|
||||
context: number
|
||||
output: number
|
||||
@@ -40,6 +55,105 @@ function createModel(opts: {
|
||||
} as Provider.Model
|
||||
}
|
||||
|
||||
async function user(sessionID: SessionID, text: string) {
|
||||
const msg = await Session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text,
|
||||
})
|
||||
return msg
|
||||
}
|
||||
|
||||
async function assistant(sessionID: SessionID, parentID: MessageID, root: string) {
|
||||
const msg: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: root, root },
|
||||
cost: 0,
|
||||
tokens: {
|
||||
output: 0,
|
||||
input: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: ref.modelID,
|
||||
providerID: ref.providerID,
|
||||
parentID,
|
||||
time: { created: Date.now() },
|
||||
finish: "end_turn",
|
||||
}
|
||||
await Session.updateMessage(msg)
|
||||
return msg
|
||||
}
|
||||
|
||||
async function tool(sessionID: SessionID, messageID: MessageID, tool: string, output: string) {
|
||||
return Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID,
|
||||
sessionID,
|
||||
type: "tool",
|
||||
callID: crypto.randomUUID(),
|
||||
tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
output,
|
||||
title: "done",
|
||||
metadata: {},
|
||||
time: { start: Date.now(), end: Date.now() },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function fake(
|
||||
input: Parameters<(typeof SessionProcessorModule.SessionProcessor)["create"]>[0],
|
||||
result: "continue" | "compact",
|
||||
): ReturnType<(typeof SessionProcessorModule.SessionProcessor)["create"]> {
|
||||
const msg = input.assistantMessage
|
||||
return {
|
||||
get message() {
|
||||
return msg
|
||||
},
|
||||
partFromToolCall() {
|
||||
return {
|
||||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID: msg.sessionID,
|
||||
type: "tool",
|
||||
callID: "fake",
|
||||
tool: "fake",
|
||||
state: { status: "pending", input: {}, raw: "" },
|
||||
}
|
||||
},
|
||||
process: async () => result,
|
||||
}
|
||||
}
|
||||
|
||||
function wait(ms = 50) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function defer() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe("session.compaction.isOverflow", () => {
|
||||
test("returns true when token count exceeds usable context", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
@@ -227,6 +341,272 @@ describe("session.compaction.isOverflow", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.compaction.create", () => {
|
||||
test("creates a compaction user message and part", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
|
||||
await SessionCompaction.create({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
auto: true,
|
||||
overflow: true,
|
||||
})
|
||||
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
expect(msgs).toHaveLength(1)
|
||||
expect(msgs[0].info.role).toBe("user")
|
||||
expect(msgs[0].parts).toHaveLength(1)
|
||||
expect(msgs[0].parts[0]).toMatchObject({
|
||||
type: "compaction",
|
||||
auto: true,
|
||||
overflow: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.compaction.prune", () => {
|
||||
test("compacts old completed tool output", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const a = await user(session.id, "first")
|
||||
const b = await assistant(session.id, a.id, tmp.path)
|
||||
await tool(session.id, b.id, "bash", "x".repeat(200_000))
|
||||
await user(session.id, "second")
|
||||
await user(session.id, "third")
|
||||
|
||||
await SessionCompaction.prune({ sessionID: session.id })
|
||||
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
|
||||
expect(part?.type).toBe("tool")
|
||||
expect(part?.state.status).toBe("completed")
|
||||
if (part?.type === "tool" && part.state.status === "completed") {
|
||||
expect(part.state.time.compacted).toBeNumber()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("skips protected skill tool output", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const a = await user(session.id, "first")
|
||||
const b = await assistant(session.id, a.id, tmp.path)
|
||||
await tool(session.id, b.id, "skill", "x".repeat(200_000))
|
||||
await user(session.id, "second")
|
||||
await user(session.id, "third")
|
||||
|
||||
await SessionCompaction.prune({ sessionID: session.id })
|
||||
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
|
||||
expect(part?.type).toBe("tool")
|
||||
if (part?.type === "tool" && part.state.status === "completed") {
|
||||
expect(part.state.time.compacted).toBeUndefined()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.compaction.process", () => {
|
||||
test("publishes compacted event on continue", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const done = defer()
|
||||
let seen = false
|
||||
const unsub = Bus.subscribe(SessionCompaction.Event.Compacted, (evt) => {
|
||||
if (evt.properties.sessionID !== session.id) return
|
||||
seen = true
|
||||
done.resolve()
|
||||
})
|
||||
|
||||
const result = await SessionCompaction.process({
|
||||
parentID: msg.id,
|
||||
messages: msgs,
|
||||
sessionID: session.id,
|
||||
abort: new AbortController().signal,
|
||||
auto: false,
|
||||
})
|
||||
|
||||
await Promise.race([
|
||||
done.promise,
|
||||
wait(500).then(() => {
|
||||
throw new Error("timed out waiting for compacted event")
|
||||
}),
|
||||
])
|
||||
unsub()
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(seen).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("marks summary message as errored on compact result", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "compact"))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const result = await SessionCompaction.process({
|
||||
parentID: msg.id,
|
||||
messages: await Session.messages({ sessionID: session.id }),
|
||||
sessionID: session.id,
|
||||
abort: new AbortController().signal,
|
||||
auto: false,
|
||||
})
|
||||
|
||||
const summary = (await Session.messages({ sessionID: session.id })).find(
|
||||
(msg) => msg.info.role === "assistant" && msg.info.summary,
|
||||
)
|
||||
|
||||
expect(result).toBe("stop")
|
||||
expect(summary?.info.role).toBe("assistant")
|
||||
if (summary?.info.role === "assistant") {
|
||||
expect(summary.info.finish).toBe("error")
|
||||
expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact")
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("adds synthetic continue prompt when auto is enabled", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
|
||||
const result = await SessionCompaction.process({
|
||||
parentID: msg.id,
|
||||
messages: await Session.messages({ sessionID: session.id }),
|
||||
sessionID: session.id,
|
||||
abort: new AbortController().signal,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const last = msgs.at(-1)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(last?.info.role).toBe("user")
|
||||
expect(last?.parts[0]).toMatchObject({
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
})
|
||||
if (last?.parts[0]?.type === "text") {
|
||||
expect(last.parts[0].text).toContain("Continue if you have next steps")
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("replays the prior user turn on overflow when earlier context exists", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
|
||||
|
||||
const session = await Session.create({})
|
||||
await user(session.id, "root")
|
||||
const replay = await user(session.id, "image")
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: replay.id,
|
||||
sessionID: session.id,
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "cat.png",
|
||||
url: "https://example.com/cat.png",
|
||||
})
|
||||
const msg = await user(session.id, "current")
|
||||
|
||||
const result = await SessionCompaction.process({
|
||||
parentID: msg.id,
|
||||
messages: await Session.messages({ sessionID: session.id }),
|
||||
sessionID: session.id,
|
||||
abort: new AbortController().signal,
|
||||
auto: true,
|
||||
overflow: true,
|
||||
})
|
||||
|
||||
const last = (await Session.messages({ sessionID: session.id })).at(-1)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(last?.info.role).toBe("user")
|
||||
expect(last?.parts.some((part) => part.type === "file")).toBe(false)
|
||||
expect(
|
||||
last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")),
|
||||
).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back to overflow guidance when no replayable turn exists", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
|
||||
|
||||
const session = await Session.create({})
|
||||
await user(session.id, "earlier")
|
||||
const msg = await user(session.id, "current")
|
||||
|
||||
const result = await SessionCompaction.process({
|
||||
parentID: msg.id,
|
||||
messages: await Session.messages({ sessionID: session.id }),
|
||||
sessionID: session.id,
|
||||
abort: new AbortController().signal,
|
||||
auto: true,
|
||||
overflow: true,
|
||||
})
|
||||
|
||||
const last = (await Session.messages({ sessionID: session.id })).at(-1)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(last?.info.role).toBe("user")
|
||||
if (last?.parts[0]?.type === "text") {
|
||||
expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit")
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("util.token.estimate", () => {
|
||||
test("estimates tokens from text (4 chars per token)", () => {
|
||||
const text = "x".repeat(4000)
|
||||
|
||||
Reference in New Issue
Block a user