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
6 changes: 6 additions & 0 deletions packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export const Instance = {
get directory() {
return context.use().directory
},
// Like `directory`, but undefined outside an instance context instead of
// throwing — for module state that wants to scope itself by directory but
// may also be touched before an instance is provided.
get directoryOrUndefined() {
return context.tryUse()?.directory
},
get worktree() {
return context.use().worktree
},
Expand Down
78 changes: 74 additions & 4 deletions packages/opencode/src/tool/read-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,83 @@ import type * as Tool from "./tool"
import { SessionCwd } from "./session-cwd"
import { AppFileSystem } from "@mimo-ai/shared/filesystem"
import { RecoverableError } from "./recoverable"
import { registerDisposer } from "@/effect/instance-registry"
import { Instance } from "@/project/instance"
import type { SessionID } from "../session/schema"

// Same normalization both sides of the comparison go through so a Read on
// a relative path lines up with an Edit on the absolute one.
const MAIN_ACTOR_ID = "main"
type ReadContext = Pick<Tool.Context, "sessionID" | "actorID">

// Per actor, group read paths by the instance directory that owns them. A
// session can span several directories, and keeping the directory below the
// actor level avoids overwriting marks if an actor ever reads in more than one.
type ActorReads = Map<string | undefined, Set<string>>
const readState = new Map<SessionID, Map<string, ActorReads>>()

// A server can host several project instances at once, and disposeInstance()
// runs every disposer with the directory being torn down. Drop only the marks
// owned by that directory; clearing more would wipe other projects' (or, on a
// shared session, other actors') live marks and make their next edit fail
// "has not been read".
registerDisposer(async (directory) => {
for (const [sessionID, actors] of readState) {
for (const [actor, readsByDirectory] of actors) {
readsByDirectory.delete(directory)
if (readsByDirectory.size === 0) actors.delete(actor)
}
if (actors.size === 0) readState.delete(sessionID)
}
})

function canon(sessionID: SessionID, p: string): string {
const abs = path.isAbsolute(p) ? p : path.resolve(SessionCwd.get(sessionID), p)
if (process.platform === "win32") return AppFileSystem.normalizePath(abs).toLowerCase()
return abs
const resolved = AppFileSystem.resolve(abs)
if (process.platform === "win32") return resolved.toLowerCase()
return resolved
}

function actorID(ctx: ReadContext) {
return ctx.actorID ?? MAIN_ACTOR_ID
}

function sessionActors(sessionID: SessionID) {
const existing = readState.get(sessionID)
if (existing) return existing

const next = new Map<string, ActorReads>()
readState.set(sessionID, next)
return next
}

function actorReads(ctx: ReadContext) {
const actors = sessionActors(ctx.sessionID)
const existing = actors.get(actorID(ctx))
if (existing) return existing

const next: ActorReads = new Map()
actors.set(actorID(ctx), next)
return next
}

export function markFileRead(ctx: ReadContext, targetPath: string): void {
const reads = actorReads(ctx)
const target = canon(ctx.sessionID, targetPath)
const directory = Instance.directoryOrUndefined
const existing = reads.get(directory)
if (existing) {
existing.add(target)
return
}

reads.set(directory, new Set([target]))
}

export function clearReadState(sessionID?: SessionID): void {
if (!sessionID) {
readState.clear()
return
}
readState.delete(sessionID)
}

/**
Expand All @@ -25,6 +94,7 @@ function canon(sessionID: SessionID, p: string): string {
*/
export function assertFileRead(ctx: Tool.Context, targetPath: string, toolId: string): void {
const target = canon(ctx.sessionID, targetPath)
if ([...(readState.get(ctx.sessionID)?.get(actorID(ctx))?.values() ?? [])].some((paths) => paths.has(target))) return

for (const msg of ctx.messages) {
for (const part of msg.parts) {
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { assertExternalDirectoryEffect } from "./external-directory"
import { SessionCwd } from "./session-cwd"
import { Instruction } from "../session/instruction"
import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media"
import { markFileRead } from "./read-state"

const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
Expand Down Expand Up @@ -183,6 +184,7 @@ export const ReadTool = Tool.define(
const start = offset - 1
const sliced = items.slice(start, start + limit)
const truncated = start + sliced.length < items.length
markFileRead(ctx, filepath)

return {
title,
Expand Down Expand Up @@ -211,6 +213,7 @@ export const ReadTool = Tool.define(
if (isImageAttachment(mime) || isPdfAttachment(mime)) {
const bytes = yield* fs.readFile(filepath)
const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully"
markFileRead(ctx, filepath)
return {
title,
output: msg,
Expand Down Expand Up @@ -263,6 +266,8 @@ export const ReadTool = Tool.define(
output += `\n\n<system-reminder>\n${loaded.map((item) => item.content).join("\n\n")}\n</system-reminder>`
}

markFileRead(ctx, filepath)

return {
title,
output,
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/util/local-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export function create<T>(name: string) {
}
return result
},
tryUse() {
return storage.getStore()
},
provide<R>(value: T, fn: () => R) {
return storage.run(value, fn)
},
Expand Down
17 changes: 17 additions & 0 deletions packages/opencode/test/tool/edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { BusEvent } from "../../src/bus/bus-event"
import { Truncate } from "../../src/tool"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import type { MessageV2 } from "../../src/session/message-v2"
import { clearReadState, markFileRead } from "../../src/tool/read-state"

const baseCtx = {
sessionID: SessionID.make("ses_test-edit-session"),
Expand Down Expand Up @@ -65,6 +66,7 @@ function withRead(filePath: string, ctx: EditCtx = baseCtx): EditCtx {
const ctx = baseCtx

afterEach(async () => {
clearReadState(ctx.sessionID)
await Instance.disposeAll()
})

Expand Down Expand Up @@ -94,6 +96,8 @@ const resolve = () =>
const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))

const markRead = (filePath: string) => markFileRead(ctx, filePath)

async function onceBus<D extends BusEvent.Definition>(def: D) {
const result = Promise.withResolvers<void>()
const unsub = await subscribeBus(def, () => {
Expand Down Expand Up @@ -198,6 +202,7 @@ describe("tool.edit", () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "existing.txt")
await fs.writeFile(filepath, "old content here", "utf-8")
markRead(filepath)

await Instance.provide({
directory: tmp.path,
Expand Down Expand Up @@ -225,6 +230,9 @@ describe("tool.edit", () => {
test("throws error when file does not exist", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nonexistent.txt")
await fs.writeFile(filepath, "old", "utf-8")
markRead(filepath)
await fs.unlink(filepath)

await Instance.provide({
directory: tmp.path,
Expand Down Expand Up @@ -275,6 +283,7 @@ describe("tool.edit", () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "actual content", "utf-8")
markRead(filepath)

await Instance.provide({
directory: tmp.path,
Expand All @@ -300,6 +309,7 @@ describe("tool.edit", () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
markRead(filepath)

await Instance.provide({
directory: tmp.path,
Expand Down Expand Up @@ -327,6 +337,7 @@ describe("tool.edit", () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original", "utf-8")
markRead(filepath)

await Instance.provide({
directory: tmp.path,
Expand Down Expand Up @@ -362,6 +373,7 @@ describe("tool.edit", () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
markRead(filepath)

await Instance.provide({
directory: tmp.path,
Expand All @@ -388,6 +400,7 @@ describe("tool.edit", () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
markRead(filepath)

await Instance.provide({
directory: tmp.path,
Expand Down Expand Up @@ -439,6 +452,7 @@ describe("tool.edit", () => {
await using tmp = await tmpdir()
const dirpath = path.join(tmp.path, "adir")
await fs.mkdir(dirpath)
markRead(dirpath)

await Instance.provide({
directory: tmp.path,
Expand All @@ -464,6 +478,7 @@ describe("tool.edit", () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
markRead(filepath)

await Instance.provide({
directory: tmp.path,
Expand Down Expand Up @@ -539,6 +554,7 @@ describe("tool.edit", () => {
fn: async () => {
const edit = await resolve()
const filePath = path.join(tmp.path, "test.txt")
markRead(filePath)
await Effect.runPromise(
edit.execute(
{
Expand Down Expand Up @@ -677,6 +693,7 @@ describe("tool.edit", () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8")
markRead(filepath)

await Instance.provide({
directory: tmp.path,
Expand Down
Loading
Loading