Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 20 additions & 0 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,23 @@ 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))
if (!match) return undefined
const text = match.parts
.filter((p): p is MessageV2.TextPart => p.type === "text" && !p.synthetic)
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SessionCompaction.lastPrompt currently treats TextPart.ignored content as eligible for the preserved prompt. But ignored text parts are intentionally excluded from MessageV2.toModelMessages, so including them here can leak user-only/ignored content into the post-compaction context and may select the wrong “last prompt”. Consider filtering with the same criteria as toModelMessages (e.g., require p.type === "text" && !p.synthetic && !p.ignored) both in the findLast predicate and the filter/map pipeline.

Suggested change
const match = msgs.findLast((m) => m.info.role === "user" && m.parts.some((p) => p.type === "text" && !p.synthetic))
if (!match) return undefined
const text = match.parts
.filter((p): p is MessageV2.TextPart => p.type === "text" && !p.synthetic)
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)

Copilot uses AI. Check for mistakes.
.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 +254,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 +274,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