Compare commits

..

1 Commits

Author SHA1 Message Date
Hona ef6d6fe493 fix(opencode): respect repository git semantics 2026-08-12 07:30:23 +00:00
11 changed files with 246 additions and 222 deletions
+6 -2
View File
@@ -30,11 +30,15 @@ Guidelines:
Complete the user's search request efficiently and report your findings clearly.`
const PROMPT_COMPACTION = `You are a context summarization agent. You are given a conversation between a user and an agent. Your goal is to produce a structured summary matching the format specified so another coding agent can continue the work.
const PROMPT_COMPACTION = `You are an anchored context summarization assistant for coding sessions.
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
Do not continue the conversation. Do not respond to any questions in the conversation. Only output the structured summary in the exact format requested by the user prompt. Respond in the same language as the conversation.`
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.`
const PROMPT_TITLE = `You are a title generator. You output ONLY a thread title. Nothing else.
+6 -22
View File
@@ -44,14 +44,6 @@ Rules:
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
- Do not mention the summary process or that context was compacted.`
const SUMMARY_UPDATE_INSTRUCTIONS = `Update the existing anchored summary with the new information.
When updating:
- Preserve all information from the <prior-summary> unless the new conversation shows that it is inaccurate, superseded, or no longer applicable.
- Add new progress, decisions, constraints, and context from the conversation.
- Move completed work from "Active" to "Completed".
- If a blocker has been resolved, update the summary to reflect that while keeping any details still needed to continue the work.
- Update "Objective" and "Next Move" to reflect the current work state.`
type Entry = {
readonly seq: number
@@ -166,22 +158,14 @@ const select = (
}
}
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) => {
const conversation = `Here is the conversation so far:\n\n<conversation>\n${input.context.join("\n\n")}\n</conversation>`
if (!input.previousSummary)
return [
conversation,
"Create a new anchored summary from the conversation history in the <conversation> tags above so another coding agent can continue the work.",
SUMMARY_TEMPLATE,
].join("\n\n")
return [
conversation,
`Here is the previous summary:\n\n<prior-summary>\n${input.previousSummary}\n</prior-summary>`,
"The <conversation> tags above contain new conversation history to incorporate into the existing summary in the <prior-summary> tags.",
SUMMARY_UPDATE_INSTRUCTIONS,
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
[
input.previousSummary
? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
: "Create a new anchored summary from the conversation history.",
SUMMARY_TEMPLATE,
...input.context,
].join("\n\n")
}
export const make = (dependencies: Dependencies) => {
const config = settings(dependencies.config)
@@ -4,33 +4,12 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
test("compaction prompt preserves detailed work state and relevant files", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
expect(prompt).toStartWith(
"Here is the conversation so far:\n\n<conversation>\nconversation history\n</conversation>",
)
expect(prompt.indexOf("</conversation>")).toBeLessThan(prompt.indexOf("Create a new anchored summary"))
expect(prompt).toContain("conversation history in the <conversation> tags above")
expect(prompt).toContain("## Work State\n### Completed")
expect(prompt).toContain("### Active")
expect(prompt).toContain("### Blocked")
expect(prompt).toContain("## Relevant Files")
})
test("compaction prompt gives explicit update instructions for a prior summary", () => {
const prompt = SessionCompaction.buildPrompt({
context: ["new conversation"],
previousSummary: "existing summary",
})
expect(prompt.indexOf("<conversation>")).toBeLessThan(prompt.indexOf("<prior-summary>"))
expect(prompt.indexOf("<prior-summary>")).toBeLessThan(prompt.indexOf("When updating:"))
expect(prompt).toContain(
"Preserve all information from the <prior-summary> unless the new conversation shows that it is inaccurate, superseded, or no longer applicable.",
)
expect(prompt).toContain(
"If a blocker has been resolved, update the summary to reflect that while keeping any details still needed to continue the work.",
)
})
test("compaction describes tool media without embedding base64", () => {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const serialized = SessionCompaction.serializeToolContent([
+1 -1
View File
@@ -1135,7 +1135,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain(
"<prior-summary>\n## Objective\n- Preserve the task\n</prior-summary>",
"<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>",
)
expect(userTexts(requests[0])[0]).toContain("Recent exact request")
expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({
@@ -1,5 +1,9 @@
You are a context summarization agent. You are given a conversation between a user and an agent. Your goal is to produce a structured summary matching the format specified so another coding agent can continue the work.
You are an anchored context summarization assistant for coding sessions.
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
Do not continue the conversation. Do not respond to any questions in the conversation. Only output the structured summary in the exact format requested by the user prompt. Respond in the same language as the conversation.
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.
-4
View File
@@ -6,14 +6,10 @@ import { ChildProcess } from "effect/unstable/process"
const cfg = [
"--no-optional-locks",
"-c",
"core.autocrlf=false",
"-c",
"core.fsmonitor=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
] as const
+2 -15
View File
@@ -381,20 +381,10 @@ const layer = Layer.effect(
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const conversation = msgs.map(serialize).filter(Boolean).join("\n\n")
const nextPrompt =
compacting.prompt ??
[
buildPrompt({
previousSummary,
context: [conversation],
}),
...compacting.context,
]
.filter(Boolean)
.join("\n\n")
const ctx = yield* InstanceState.context
const msg: SessionV1.Assistant = {
id: MessageID.ascending(),
@@ -440,10 +430,7 @@ const layer = Layer.effect(
content: [
{
type: "text",
text: [
nextPrompt,
...(compacting.prompt ? ["The following is the conversation history:", conversation] : []),
]
text: [nextPrompt, "The following is the conversation history:", conversation]
.filter(Boolean)
.join("\n\n"),
},
+114 -89
View File
@@ -22,9 +22,11 @@ export type FileDiff = typeof FileDiff.Type
const prune = "7.days"
const limit = 2 * 1024 * 1024
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const cfg = ["-c", "core.autocrlf=false", ...core]
const quote = [...cfg, "-c", "core.quotepath=false"]
// Operational flags are safe for both repositories. Working-tree semantics are
// resolved from the source repository and persisted in the private snapshot repository.
const operational = ["-c", "core.longpaths=true"]
const quote = [...operational, "-c", "core.quotepath=false"]
const mirrored = ["core.autocrlf", "core.symlinks"]
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode
readonly text: string
@@ -99,27 +101,25 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
),
)
const required = Effect.fnUntraced(function* (
cmd: string[],
opts?: { cwd?: string; env?: Record<string, string>; stdin?: string },
) {
const result = yield* git(cmd, opts)
if (result.code === 0) return result
return yield* Effect.die(
new Error(`git ${cmd.join(" ")} failed with exit code ${result.code}: ${result.stderr.trim()}`),
)
})
const ignore = Effect.fnUntraced(function* (files: string[]) {
if (!files.length) return new Set<string>()
// check-ignore treats a leading colon as pathspec magic but accepts and echoes a protective ./ prefix.
const checkIgnorePaths = files.map((item) => (item.startsWith(":") ? `./${item}` : item))
const check = yield* git(
[
...quote,
"--git-dir",
path.join(state.worktree, ".git"),
"--work-tree",
state.worktree,
"check-ignore",
"--no-index",
"--stdin",
"-z",
],
{
cwd: state.worktree,
stdin: encodeNulTerminatedPaths(checkIgnorePaths),
},
)
const check = yield* git([...quote, "check-ignore", "--no-index", "--stdin", "-z"], {
cwd: state.worktree,
stdin: encodeNulTerminatedPaths(checkIgnorePaths),
})
if (check.code !== 0 && check.code !== 1) return new Set<string>()
return new Set(
check.text
@@ -131,9 +131,9 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const drop = Effect.fnUntraced(function* (files: string[]) {
if (!files.length) return
yield* git(
yield* required(
[
...cfg,
...operational,
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
],
{
@@ -145,18 +145,13 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const stage = Effect.fnUntraced(function* (files: string[]) {
if (!files.length) return
const result = yield* git(
[...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
yield* required(
[...operational, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
{
cwd: state.worktree,
stdin: encodeTopLevelLiteralPathspecs(files),
},
)
if (result.code === 0) return
yield* Effect.logWarning("failed to add snapshot files", {
exitCode: result.code,
stderr: result.stderr,
})
})
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
@@ -226,12 +221,62 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
// Seed the index from the source repo so already-hashed entries are reused.
// Best-effort: a missing/incompatible index just falls back to a full add.
const sourceIndex = path.join(source, "index")
const index = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "index"], {
cwd: state.worktree,
})
const sourceIndex = index.text.trim()
if (yield* exists(sourceIndex)) {
yield* fs.copyFile(sourceIndex, path.join(state.gitdir, "index")).pipe(Effect.catch(() => Effect.void))
}
})
const prepare = Effect.fnUntraced(function* () {
const existed = yield* exists(state.gitdir)
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
if (!(yield* exists(path.join(state.gitdir, "config")))) {
yield* required(["init"], {
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
})
}
const semantics = yield* Effect.forEach(mirrored, (key) =>
git(["config", "--get", key], { cwd: state.worktree }).pipe(
Effect.map((result) => {
if (result.code === 0) return [key, result.text.trim()] as const
if (result.code === 1) return [key, key === "core.autocrlf" ? "false" : "true"] as const
throw new Error(`failed to resolve ${key}: ${result.stderr.trim()}`)
}),
),
)
const fingerprint = `v1;${semantics.map(([key, value]) => `${key}=${value}`).join(";")}`
const previous = yield* git([
"--git-dir",
state.gitdir,
"config",
"--local",
"--get",
"opencode.snapshotSemantics",
])
if (previous.text.trim() === fingerprint) return
const config = [
...semantics,
["core.longpaths", "true"],
["core.fsmonitor", "false"],
["feature.manyFiles", "true"],
["index.version", "4"],
["index.threads", "true"],
["core.untrackedCache", "true"],
] as const
yield* Effect.forEach(
config,
([key, value]) => required(["--git-dir", state.gitdir, "config", "--local", key, value]),
{ discard: true },
)
yield* seed()
yield* required(["--git-dir", state.gitdir, "config", "--local", "opencode.snapshotSemantics", fingerprint])
if (!existed) yield* Effect.logInfo("initialized")
})
const add = Effect.fnUntraced(function* () {
yield* sync()
const [diff, other] = yield* Effect.all(
@@ -246,13 +291,11 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
{ concurrency: 2 },
)
if (diff.code !== 0 || other.code !== 0) {
yield* Effect.logWarning("failed to list snapshot files", {
diffCode: diff.code,
diffStderr: diff.stderr,
otherCode: other.code,
otherStderr: other.stderr,
})
return
return yield* Effect.die(
new Error(
`failed to list snapshot files: diff-files=${diff.code} ${diff.stderr.trim()} ls-files=${other.code} ${other.stderr.trim()}`,
),
)
}
const tracked = diff.text.split("\0").filter(Boolean)
@@ -319,26 +362,9 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
return yield* locked(
Effect.gen(function* () {
if (!(yield* enabled())) return
const existed = yield* exists(state.gitdir)
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
})
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
// Tuning for very large worktrees so the first add stays bounded.
yield* git(["--git-dir", state.gitdir, "config", "feature.manyFiles", "true"])
yield* git(["--git-dir", state.gitdir, "config", "index.version", "4"])
yield* git(["--git-dir", state.gitdir, "config", "index.threads", "true"])
yield* git(["--git-dir", state.gitdir, "config", "core.untrackedCache", "true"])
yield* seed()
yield* Effect.logInfo("initialized")
}
yield* prepare()
yield* add()
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
const result = yield* required(args(["write-tree"]), { cwd: state.directory })
const hash = result.text.trim()
yield* Effect.logInfo("tracking", { hash, cwd: state.directory, git: state.gitdir })
return hash
@@ -349,6 +375,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const patch = Effect.fnUntraced(function* (hash: string) {
return yield* locked(
Effect.gen(function* () {
yield* prepare()
yield* add()
const result = yield* git(
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
@@ -382,24 +409,11 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const restore = Effect.fnUntraced(function* (snapshot: string) {
return yield* locked(
Effect.gen(function* () {
yield* prepare()
yield* Effect.logInfo("restore", { commit: snapshot })
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
cwd: state.worktree,
})
if (checkout.code === 0) return
yield* Effect.logError("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
yield* Effect.logError("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
yield* required([...operational, ...args(["read-tree", snapshot])], { cwd: state.worktree })
yield* required([...operational, ...args(["checkout-index", "-a", "-f"])], {
cwd: state.worktree,
})
}),
)
@@ -408,6 +422,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const revert = Effect.fnUntraced(function* (patches: Patch[]) {
return yield* locked(
Effect.gen(function* () {
yield* prepare()
const ops: { hash: string; file: string; rel: string }[] = []
const seen = new Set<string>()
for (const item of patches) {
@@ -424,20 +439,21 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) {
yield* Effect.logInfo("reverting", { file: op.file, hash: op.hash })
const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], {
const result = yield* git([...operational, ...args(["checkout", op.hash, "--", op.file])], {
cwd: state.worktree,
})
if (result.code === 0) return
const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], {
const tree = yield* git([...operational, ...args(["ls-tree", op.hash, "--", op.rel])], {
cwd: state.worktree,
})
if (tree.code === 0 && tree.text.trim()) {
yield* Effect.logInfo("file existed in snapshot but checkout failed, keeping", {
file: op.file,
hash: op.hash,
})
return
}
if (tree.code !== 0)
yield* Effect.die(
new Error(`failed to inspect ${op.file} in snapshot ${op.hash}: ${tree.stderr.trim()}`),
)
if (tree.text.trim())
yield* Effect.die(
new Error(`failed to restore ${op.file} from snapshot ${op.hash}: ${result.stderr.trim()}`),
)
yield* Effect.logInfo("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
yield* remove(op.file)
})
@@ -464,7 +480,10 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
}
const tree = yield* git(
[...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])],
[
...operational,
...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)]),
],
{
cwd: state.worktree,
},
@@ -493,7 +512,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
if (list.length) {
yield* Effect.logInfo("reverting", { hash: first.hash, files: list.length })
const result = yield* git(
[...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])],
[...operational, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])],
{
cwd: state.worktree,
},
@@ -526,6 +545,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const diff = Effect.fnUntraced(function* (hash: string) {
return yield* locked(
Effect.gen(function* () {
yield* prepare()
yield* add()
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
cwd: state.worktree,
@@ -546,6 +566,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
return yield* locked(
Effect.gen(function* () {
yield* prepare()
type Row = {
file: string
status: "added" | "deleted" | "modified"
@@ -565,12 +586,14 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
if (row.status === "added") {
return [
"",
yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
yield* git([...operational, ...args(["show", `${to}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
]
}
if (row.status === "deleted") {
return [
yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(
yield* git([...operational, ...args(["show", `${from}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
"",
@@ -578,8 +601,10 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
}
return yield* Effect.all(
[
git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
git([...operational, ...args(["show", `${from}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
git([...operational, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
],
{ concurrency: 2 },
)
@@ -602,7 +627,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
if (!refs.length) return new Map<string, { before: string; after: string }>()
const batch = yield* appProcess.run(
ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
ChildProcess.make("git", [...operational, ...args(["cat-file", "--batch"])], {
cwd: state.directory,
extendEnv: true,
}),
+33
View File
@@ -115,6 +115,39 @@ describe("Git", () => {
}),
)
it.live("respects repository line ending configuration", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(tmp.path).quiet())
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "line-endings.txt"), "before\n", "utf-8"))
yield* Effect.promise(() => $`git add line-endings.txt`.cwd(tmp.path).quiet())
yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add line endings"`.cwd(tmp.path).quiet())
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "line-endings.txt")))
yield* Effect.promise(() => $`git checkout -- line-endings.txt`.cwd(tmp.path).quiet())
const git = yield* Git.Service
expect(yield* git.status(tmp.path)).toEqual([])
expect(yield* git.diff(tmp.path, "HEAD")).toEqual([])
}),
)
it.live("respects repository symlink configuration", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
const blob = yield* Effect.promise(() => $`echo -n target.txt | git hash-object -w --stdin`.cwd(tmp.path).text())
yield* Effect.promise(() =>
$`git update-index --add --cacheinfo 120000,${blob.trim()},link.txt`.cwd(tmp.path).quiet(),
)
yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add symlink"`.cwd(tmp.path).quiet())
yield* Effect.promise(() => $`git config core.symlinks false`.cwd(tmp.path).quiet())
yield* Effect.promise(() => $`git checkout-index -f link.txt`.cwd(tmp.path).quiet())
const git = yield* Git.Service
expect(yield* git.status(tmp.path)).toEqual([])
expect(yield* git.diff(tmp.path, "HEAD")).toEqual([])
}),
)
it.live("patch() returns capped native patch output", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
@@ -365,20 +365,6 @@ function autocontinue(enabled: boolean) {
})
}
function compactionContext(context: string) {
return Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
if (name !== "experimental.session.compacting") return Effect.succeed(output)
return Effect.sync(() => {
;(output as { context: string[] }).context.push(context)
return output
})
},
list: () => Effect.succeed([]),
init: () => Effect.void,
})
}
describe("session.compaction.isOverflow", () => {
it.live(
"returns true when token count exceeds usable context",
@@ -1403,11 +1389,6 @@ describe("session.compaction.process", () => {
const captured = JSON.stringify(messages)
expect(messages).toHaveLength(1)
expect(messages[0]?.role).toBe("user")
expect(captured).toContain("Here is the conversation so far:")
expect(captured).toContain("<conversation>")
expect(captured.indexOf("[User]: older context")).toBeLessThan(
captured.indexOf("Create a new anchored summary"),
)
expect(captured).toContain("[User]: older context")
expect(captured).not.toContain("keep this turn")
expect(captured).not.toContain("and this one too")
@@ -1449,11 +1430,9 @@ describe("session.compaction.process", () => {
expect(parent).toBeTruthy()
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
expect(captured).toContain("<prior-summary>")
expect(captured).toContain("<previous-summary>")
expect(captured).toContain("summary one")
expect(captured.match(/summary one/g)?.length).toBe(1)
expect(captured.indexOf("latest turn")).toBeLessThan(captured.indexOf("Here is the previous summary:"))
expect(captured).toContain("existing summary in the <prior-summary> tags")
expect(captured).toContain("## Important Details")
expect(captured).toContain("## Work State")
}).pipe(withCompaction({ llm: stub.llmLayer }))
@@ -1461,49 +1440,6 @@ describe("session.compaction.process", () => {
{ git: true },
)
itCompaction.instance(
"keeps plugin context outside the serialized conversation",
() => {
const stub = llm()
let captured = ""
stub.push(
reply("summary", (input) => {
captured = JSON.stringify(input.messages)
}),
)
return Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "older context")
yield* createUserMessage(session.id, "keep this turn")
yield* createUserMessage(session.id, "and this one too")
yield* createCompactionMarker(session.id)
const msgs = yield* ssn.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
yield* SessionCompaction.use.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
})
expect(captured).toContain("Prioritize unresolved migration details")
expect(captured.indexOf("</conversation>")).toBeLessThan(
captured.indexOf("Prioritize unresolved migration details"),
)
}).pipe(
withCompaction({
llm: stub.llmLayer,
plugin: compactionContext("Prioritize unresolved migration details"),
}),
)
},
{ git: true },
)
itCompaction.instance(
"serializes repeated compaction history as one user message",
() => {
@@ -5,7 +5,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import fs from "fs/promises"
import path from "path"
import { Effect, Fiber, Layer } from "effect"
import { Effect, Exit, Fiber, Layer } from "effect"
import { Snapshot } from "../../src/snapshot"
import {
disposeAllInstances,
@@ -117,6 +117,68 @@ it.instance(
{ git: true },
)
it.instance(
"uses source repository line ending semantics",
Effect.gen(function* () {
const tmp = yield* TestInstance
const file = path.join(tmp.directory, "line-endings.txt")
yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "true"])
yield* write(file, "before\n")
yield* exec(tmp.directory, ["git", "add", "line-endings.txt"])
yield* exec(tmp.directory, ["git", "commit", "--no-gpg-sign", "-m", "add line endings"])
yield* rm(file)
yield* exec(tmp.directory, ["git", "checkout", "--", "line-endings.txt"])
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.track()
yield* write(file, "before\n")
const after = yield* snapshot.track()
expect(after).toBe(before)
yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "false"])
yield* write(file, "before\r\n")
expect(yield* snapshot.track()).not.toBe(before)
yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "true"])
expect(yield* snapshot.track()).toBe(before)
yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "input"])
yield* write(file, "changed\n")
yield* snapshot.restore(before!)
expect(yield* readText(file)).toBe("before\n")
yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "true"])
yield* write(file, "changed\n")
yield* snapshot.restore(before!)
expect(yield* readText(file)).toBe("before\r\n")
}),
{ git: true },
)
it.instance(
"uses source repository symlink semantics",
Effect.gen(function* () {
const tmp = yield* TestInstance
const blob = yield* Effect.promise(() =>
$`printf target.txt | git hash-object -w --stdin`.cwd(tmp.directory).text(),
)
yield* exec(tmp.directory, ["git", "update-index", "--add", "--cacheinfo", `120000,${blob.trim()},link.txt`])
yield* exec(tmp.directory, ["git", "commit", "--no-gpg-sign", "-m", "add symlink"])
yield* exec(tmp.directory, ["git", "config", "core.symlinks", "false"])
yield* exec(tmp.directory, ["git", "checkout-index", "-f", "link.txt"])
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.track()
yield* exec(tmp.directory, ["git", "config", "core.symlinks", "true"])
expect(yield* snapshot.track()).not.toBe(before)
yield* exec(tmp.directory, ["git", "config", "core.symlinks", "false"])
expect(yield* snapshot.track()).toBe(before)
}),
{ git: true },
)
it.instance(
"revert should remove new files",
withTrackedSnapshot(({ tmp, snapshot, before }) =>
@@ -287,6 +349,20 @@ it.instance(
{ git: true },
)
it.instance(
"revert preserves files when snapshot lookup fails",
withTrackedSnapshot(({ tmp, snapshot }) =>
Effect.gen(function* () {
const file = path.join(tmp.path, "protected.txt")
yield* write(file, "keep")
const result = yield* Effect.exit(snapshot.revert([{ hash: "invalid-hash", files: [file] }]))
expect(Exit.isFailure(result)).toBe(true)
expect(yield* readText(file)).toBe("keep")
}),
),
{ git: true },
)
it.instance(
"unicode filenames",
withTrackedSnapshot(({ tmp, snapshot, before }) =>