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
1 change: 1 addition & 0 deletions packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ export const SessionRoutes = lazy(() =>
modelID: body.modelID,
},
auto: body.auto,
prompt: SessionCompaction.lastPrompt(msgs),
})
await SessionPrompt.loop({ sessionID })
return c.json(true)
Expand Down
22 changes: 22 additions & 0 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,25 @@ When constructing the summary, try to stick to this template:
return "continue"
}

export function lastPrompt(msgs: MessageV2.WithParts[]) {
const match = msgs.findLast(
(m) => m.info.role === "user" && m.parts.some((p) => p.type === "text" && !p.synthetic && !p.ignored),
)
if (!match) return undefined
const text = match.parts
.filter((p): p is MessageV2.TextPart => p.type === "text" && !p.synthetic && !p.ignored)
.map((p) => p.text)
.join("\n")
return text || undefined
}

const PROMPT_MAX = 2500

export function truncate(text: string, max = PROMPT_MAX) {
if (text.length <= max) return text
return text.slice(0, max) + "..."
}

export const create = fn(
z.object({
sessionID: Identifier.schema("session"),
Expand All @@ -237,8 +256,10 @@ When constructing the summary, try to stick to this template:
modelID: z.string(),
}),
auto: z.boolean(),
prompt: z.string().optional(),
}),
async (input) => {
const prompt = input.prompt ? truncate(input.prompt) : undefined
const msg = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
Expand All @@ -255,6 +276,7 @@ When constructing the summary, try to stick to this template:
sessionID: msg.sessionID,
type: "compaction",
auto: input.auto,
prompt,
})
},
)
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export namespace MessageV2 {
export const CompactionPart = PartBase.extend({
type: z.literal("compaction"),
auto: z.boolean(),
prompt: z.string().optional(),
}).meta({
ref: "CompactionPart",
})
Expand Down Expand Up @@ -573,7 +574,7 @@ export namespace MessageV2 {
if (part.type === "compaction") {
userMessage.parts.push({
type: "text",
text: "What did we do so far?",
text: part.prompt ? `[Compacted]\n\nOriginal request: ${part.prompt}` : "[Compacted]",
})
}
if (part.type === "subtask") {
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,7 @@ export namespace SessionPrompt {
agent: lastUser.agent,
model: lastUser.model,
auto: true,
prompt: SessionCompaction.lastPrompt(msgs),
})
continue
}
Expand Down Expand Up @@ -709,6 +710,7 @@ export namespace SessionPrompt {
agent: lastUser.agent,
model: lastUser.model,
auto: true,
prompt: SessionCompaction.lastPrompt(msgs),
})
}
continue
Expand Down
68 changes: 68 additions & 0 deletions packages/opencode/test/session/compaction.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { SessionCompaction } from "../../src/session/compaction"
import { MessageV2 } from "../../src/session/message-v2"
import { Token } from "../../src/util/token"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
Expand Down Expand Up @@ -227,6 +228,73 @@ describe("session.compaction.isOverflow", () => {
})
})

describe("session.compaction.truncate", () => {
test("returns text unchanged when within limit", () => {
const text = "a".repeat(2500)
expect(SessionCompaction.truncate(text)).toBe(text)
})

test("truncates and appends ellipsis when over limit", () => {
const text = "a".repeat(2501)
const result = SessionCompaction.truncate(text)
expect(result).toBe("a".repeat(2500) + "...")
expect(result.length).toBe(2503)
})

test("respects custom max", () => {
expect(SessionCompaction.truncate("abcdef", 3)).toBe("abc...")
})

test("returns empty string as-is", () => {
expect(SessionCompaction.truncate("")).toBe("")
})
})

describe("session.compaction.lastPrompt", () => {
const user = (id: string, parts: MessageV2.Part[]) =>
({
info: {
id,
role: "user",
sessionID: "s1",
time: { created: 0 },
agent: "user",
model: { providerID: "test", modelID: "test" },
},
parts,
}) as unknown as MessageV2.WithParts

const part = (id: string, msgID: string, text: string, synthetic?: boolean) =>
({
id,
messageID: msgID,
sessionID: "s1",
type: "text",
text,
...(synthetic ? { synthetic } : {}),
}) as MessageV2.Part

test("returns last non-synthetic user text", () => {
expect(SessionCompaction.lastPrompt([user("m1", [part("p1", "m1", "hello"), part("p2", "m1", "world")])])).toBe(
"hello\nworld",
)
})

test("skips synthetic text parts", () => {
expect(
SessionCompaction.lastPrompt([user("m1", [part("p1", "m1", "real"), part("p2", "m1", "synthetic", true)])]),
).toBe("real")
})

test("returns undefined when no user messages", () => {
const msg = {
info: { id: "m1", role: "assistant", sessionID: "s1", time: { created: 0 } },
parts: [],
} as unknown as MessageV2.WithParts
expect(SessionCompaction.lastPrompt([msg])).toBeUndefined()
})
})

describe("util.token.estimate", () => {
test("estimates tokens from text (4 chars per token)", () => {
const text = "x".repeat(4000)
Expand Down
27 changes: 26 additions & 1 deletion packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,13 +256,38 @@ describe("session.message-v2.toModelMessage", () => {
filename: "img.png",
data: "https://example.com/img.png",
},
{ type: "text", text: "What did we do so far?" },
{ type: "text", text: "[Compacted]" },
{ type: "text", text: "The following tool was executed by the user" },
],
},
])
})

test("compaction part with prompt includes original request", () => {
const messageID = "m-user"

const input: MessageV2.WithParts[] = [
{
info: userInfo(messageID),
parts: [
{
...basePart(messageID, "p1"),
type: "compaction",
auto: true,
prompt: "fix the login bug",
},
] as MessageV2.Part[],
},
]

expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "[Compacted]\n\nOriginal request: fix the login bug" }],
},
])
})

test("converts assistant tool completion into tool-call + tool-result messages with attachments", () => {
const userID = "m-user"
const assistantID = "m-assistant"
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ export type CompactionPart = {
messageID: string
type: "compaction"
auto: boolean
prompt?: string
}

export type Part =
Expand Down
Loading