From 34161dc329071e5d6b3e50fce2f94895a5c6c8c5 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 19:58:07 +0800 Subject: [PATCH 01/17] feat: track assistant turn changes --- .../src/pages/session/message-timeline.tsx | 95 ++++ .../20260503120000_turn_changes/migration.sql | 28 ++ .../opencode/src/server/instance/session.ts | 89 ++++ packages/opencode/src/session/export.ts | 4 +- packages/opencode/src/session/processor.ts | 2 + packages/opencode/src/session/revert.ts | 7 +- packages/opencode/src/session/session.sql.ts | 44 ++ packages/opencode/src/session/summary.ts | 13 +- packages/opencode/src/session/turn-change.ts | 423 ++++++++++++++++++ packages/opencode/src/share/share-next.ts | 15 +- packages/opencode/src/tool/apply_patch.ts | 68 ++- packages/opencode/src/tool/edit.ts | 64 ++- packages/opencode/src/tool/sensitive.ts | 164 +++++++ packages/opencode/src/tool/write.ts | 23 +- packages/opencode/test/session/export.test.ts | 50 +++ .../test/session/session-artifacts.test.ts | 45 ++ .../opencode/test/session/turn-change.test.ts | 181 ++++++++ .../opencode/test/share/share-next.test.ts | 68 +++ .../opencode/test/tool/apply_patch.test.ts | 55 ++- packages/opencode/test/tool/edit.test.ts | 67 +++ packages/opencode/test/tool/write.test.ts | 37 ++ packages/ui/src/components/session-turn.css | 145 ++++++ packages/ui/src/components/session-turn.tsx | 191 +++++++- 23 files changed, 1821 insertions(+), 57 deletions(-) create mode 100644 packages/opencode/migration/20260503120000_turn_changes/migration.sql create mode 100644 packages/opencode/src/session/turn-change.ts create mode 100644 packages/opencode/src/tool/sensitive.ts create mode 100644 packages/opencode/test/session/turn-change.test.ts diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 3e3130638..83adc3700 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -60,6 +60,26 @@ type UserActions = { revert?: (input: { sessionID: string; messageID: string }) => Promise | void } +type TurnChangeDisplay = { + sessionID: string + turnID: string + messageID: string + undoAvailable: boolean + redoAvailable: boolean + files: Array<{ + path: string + openPath?: string + status: "added" | "modified" | "deleted" + additions?: number + deletions?: number + patch?: string + sensitive?: boolean + binary?: boolean + large?: boolean + expandable: boolean + }> +} + const messageComments = (parts: Part[]): MessageComment[] => parts.flatMap((part) => { if (part.type !== "text" || !(part as TextPart).synthetic) return [] @@ -294,6 +314,70 @@ export function MessageTimeline(props: { const webSearchToastSurfaced = new Set() const webSearchPartCursor = new Map() const webSearchPendingParts = new Map>() + const [turnChanges, setTurnChanges] = createStore>({}) + const fetchedTurnChanges = new Set() + + const authHeaders = () => { + const current = server.current + if (!current?.http.password) return {} as Record + return { + Authorization: `Basic ${btoa(`${current.http.username ?? "opencode"}:${current.http.password}`)}`, + } + } + + const turnChangeFetch = async ( + messageID: string, + action?: "undo" | "redo", + ): Promise => { + const current = server.current + const id = sessionID() + if (!current || !id) return + const url = `${current.http.url}/session/${id}/turn-change/${messageID}${action ? `/${action}` : ""}` + const res = await fetch(url, { + method: action ? "POST" : "GET", + headers: authHeaders(), + }) + if (!res.ok) throw new Error(`turn-change ${action ?? "get"} failed: ${res.status}`) + const body = await res.json() + if (!action) { + setTurnChanges(messageID, body ?? null) + return body ?? undefined + } + if (body?.status === "applied") { + setTurnChanges(messageID, body.display) + return body.display + } + showToast({ + title: action === "undo" ? "Undo blocked" : "Redo blocked", + description: "The file changed after this turn, so PawWork did not overwrite it.", + variant: "error", + }) + return turnChanges[messageID] ?? undefined + } + + createEffect( + on( + () => + sessionMessages() + .filter((message): message is Extract => message.role === "assistant") + .filter((message) => typeof message.time.completed === "number") + .map((message) => message.id) + .join(":"), + () => { + const id = sessionID() + if (!id) return + for (const message of sessionMessages()) { + if (message.role !== "assistant" || typeof message.time.completed !== "number") continue + const key = `${id}:${message.id}` + if (fetchedTurnChanges.has(key)) continue + fetchedTurnChanges.add(key) + void turnChangeFetch(message.id).catch(() => { + setTurnChanges(message.id, null) + }) + } + }, + ), + ) onMount(() => { void emitRendererDiagnostic({ @@ -913,6 +997,17 @@ export function MessageTimeline(props: { showReasoningSummaries={settings.general.showReasoningSummaries()} shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} editToolDefaultOpen={settings.general.editToolPartsExpanded()} + turnChanges={turnChanges} + turnChangeActions={{ + undo: (messageID) => turnChangeFetch(messageID, "undo"), + redo: (messageID) => turnChangeFetch(messageID, "redo"), + openFile: (path) => { + void platform.openPath?.(path) + }, + showInFolder: (path) => { + void platform.showItemInFolder?.(path) + }, + }} classes={{ root: "min-w-0 w-full relative", content: "flex flex-col justify-between !overflow-visible", diff --git a/packages/opencode/migration/20260503120000_turn_changes/migration.sql b/packages/opencode/migration/20260503120000_turn_changes/migration.sql new file mode 100644 index 000000000..f4d036641 --- /dev/null +++ b/packages/opencode/migration/20260503120000_turn_changes/migration.sql @@ -0,0 +1,28 @@ +CREATE TABLE `turn_change_display` ( + `session_id` text NOT NULL, + `message_id` text NOT NULL, + `data` text NOT NULL, + `state` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + CONSTRAINT `fk_turn_change_display_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_turn_change_display_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE UNIQUE INDEX `turn_change_display_message_idx` ON `turn_change_display` (`session_id`,`message_id`);--> statement-breakpoint +CREATE INDEX `turn_change_display_session_idx` ON `turn_change_display` (`session_id`);--> statement-breakpoint +CREATE TABLE `turn_change_restore` ( + `session_id` text NOT NULL, + `message_id` text NOT NULL, + `file_path` text NOT NULL, + `position` integer NOT NULL, + `data` text NOT NULL, + `finalized` integer DEFAULT false NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + CONSTRAINT `fk_turn_change_restore_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_turn_change_restore_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE UNIQUE INDEX `turn_change_restore_file_idx` ON `turn_change_restore` (`session_id`,`message_id`,`file_path`);--> statement-breakpoint +CREATE INDEX `turn_change_restore_message_idx` ON `turn_change_restore` (`session_id`,`message_id`); diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index b9d96ceda..42ba0153d 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -29,6 +29,7 @@ import { errors } from "../error" import { lazy } from "../../util/lazy" import { Bus } from "../../bus" import { NamedError } from "@opencode-ai/util/error" +import { TurnChange } from "@/session/turn-change" const log = Log.create({ service: "server" }) @@ -533,6 +534,94 @@ export const SessionRoutes = lazy(() => return c.json(result) }, ) + .get( + "/:sessionID/turn-change/:messageID", + describeRoute({ + summary: "Get assistant turn changes", + description: "Get files explicitly changed by PawWork file-writing tools during one assistant turn.", + operationId: "session.turnChange", + responses: { + 200: { + description: "Turn changes", + content: { + "application/json": { + schema: resolver(TurnChange.DisplaySchema.nullable()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + messageID: MessageID.zod, + }), + ), + async (c) => { + const params = c.req.valid("param") + return c.json(TurnChange.get(params) ?? null) + }, + ) + .post( + "/:sessionID/turn-change/:messageID/undo", + describeRoute({ + summary: "Undo assistant turn file changes", + operationId: "session.turnChangeUndo", + responses: { + 200: { + description: "Undo result", + content: { + "application/json": { + schema: resolver(TurnChange.MutationResultSchema), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + messageID: MessageID.zod, + }), + ), + async (c) => { + const params = c.req.valid("param") + return c.json(await TurnChange.undo(params)) + }, + ) + .post( + "/:sessionID/turn-change/:messageID/redo", + describeRoute({ + summary: "Redo assistant turn file changes", + operationId: "session.turnChangeRedo", + responses: { + 200: { + description: "Redo result", + content: { + "application/json": { + schema: resolver(TurnChange.MutationResultSchema), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + messageID: MessageID.zod, + }), + ), + async (c) => { + const params = c.req.valid("param") + return c.json(await TurnChange.redo(params)) + }, + ) .get( "/:sessionID/artifacts", describeRoute({ diff --git a/packages/opencode/src/session/export.ts b/packages/opencode/src/session/export.ts index ac2b73e84..6683f954a 100644 --- a/packages/opencode/src/session/export.ts +++ b/packages/opencode/src/session/export.ts @@ -16,6 +16,7 @@ import { Provider } from "../provider/provider" import { ProviderID, ModelID } from "../provider/schema" import { Instance } from "../project/instance" import { Global } from "../global" +import { sanitizeSensitiveDiffs, sanitizeSensitiveToolPart } from "@/tool/sensitive" export function getRuntimeNamespace(): "pawwork" | "opencode" { return Runtime.isPawWork() ? "pawwork" : "opencode" @@ -48,6 +49,7 @@ export function redactPart( part: MessageV2.Part, ctx: { count: { omitted: number } }, ): MessageV2.Part { + part = sanitizeSensitiveToolPart(part) if (part.type === "file") { const r = redactDataUrl(part.url) if (!r) return part @@ -237,7 +239,7 @@ export namespace Export { ctx: { count: { omitted: number } }, ) { const messages = yield* svc.messages({ sessionID: info.id }) - const diffs = yield* svc.diff(info.id) + const diffs = sanitizeSensitiveDiffs(yield* svc.diff(info.id)) as SnapshotMod.FileDiff[] const children = yield* svc.children(info.id) const sorted = [...children].sort((a, b) => { if (a.time.created !== b.time.created) return a.time.created - b.time.created diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2f6f8dd39..94f9593ee 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -20,6 +20,7 @@ import { Question } from "@/question" import { errorMessage } from "@/util/error" import { Log } from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" +import { TurnChange } from "./turn-change" const log = Log.create({ service: "session.processor" }) @@ -735,6 +736,7 @@ export const layer: Layer.Layer< ctx.toolcalls = {} ctx.assistantMessage.time.completed = Date.now() yield* session.updateMessage(ctx.assistantMessage) + TurnChange.finalize({ sessionID: ctx.sessionID, messageID: ctx.assistantMessage.id }) }) const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) { diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 9e82c67e8..e83d0718f 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -11,6 +11,7 @@ import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" import { SessionRunState } from "./run-state" import { SessionSummary } from "./summary" +import { sanitizeSensitiveDiffs } from "@/tool/sensitive" const log = Log.create({ service: "session.revert" }) @@ -79,15 +80,15 @@ export const layer = Layer.effect( yield* snap.revert(patches) if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string) const range = all.filter((msg) => msg.info.id >= rev!.messageID) - const diffs = yield* summary.computeDiff({ messages: range }) + const diffs = sanitizeSensitiveDiffs(yield* summary.computeDiff({ messages: range })) as Snapshot.FileDiff[] yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) yield* sessions.setRevert({ sessionID: input.sessionID, revert: rev, summary: { - additions: diffs.reduce((sum, x) => sum + x.additions, 0), - deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), + additions: diffs.reduce((sum, x) => sum + (x.additions ?? 0), 0), + deletions: diffs.reduce((sum, x) => sum + (x.deletions ?? 0), 0), files: diffs.length, }, }) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 5dd610124..8e4600a6a 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -131,6 +131,50 @@ export const SessionEntryTable = sqliteTable( ], ) +export const TurnChangeDisplayTable = sqliteTable( + "turn_change_display", + { + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + message_id: text() + .$type() + .notNull() + .references(() => MessageTable.id, { onDelete: "cascade" }), + data: text({ mode: "json" }).notNull().$type(), + state: text().$type<"applied" | "undone" | "redo_invalidated">().notNull(), + ...Timestamps, + }, + (table) => [ + uniqueIndex("turn_change_display_message_idx").on(table.session_id, table.message_id), + index("turn_change_display_session_idx").on(table.session_id), + ], +) + +export const TurnChangeRestoreTable = sqliteTable( + "turn_change_restore", + { + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + message_id: text() + .$type() + .notNull() + .references(() => MessageTable.id, { onDelete: "cascade" }), + file_path: text().notNull(), + position: integer().notNull(), + data: text({ mode: "json" }).notNull().$type(), + finalized: integer({ mode: "boolean" }).notNull().default(false), + ...Timestamps, + }, + (table) => [ + uniqueIndex("turn_change_restore_file_idx").on(table.session_id, table.message_id, table.file_path), + index("turn_change_restore_message_idx").on(table.session_id, table.message_id), + ], +) + export const PermissionTable = sqliteTable("permission", { project_id: text() .primaryKey() diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 991360129..ad8b0a714 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -7,6 +7,7 @@ import { Storage } from "@/storage/storage" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" +import { sanitizeSensitiveDiffs } from "@/tool/sensitive" export namespace SessionSummary { export const Artifact = z @@ -121,12 +122,12 @@ export namespace SessionSummary { const all = yield* sessions.messages({ sessionID: input.sessionID }) if (!all.length) return - const diffs = yield* computeDiff({ messages: all }) + const diffs = sanitizeSensitiveDiffs(yield* computeDiff({ messages: all })) as Snapshot.FileDiff[] yield* sessions.setSummary({ sessionID: input.sessionID, summary: { - additions: diffs.reduce((sum, x) => sum + x.additions, 0), - deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), + additions: diffs.reduce((sum, x) => sum + (x.additions ?? 0), 0), + deletions: diffs.reduce((sum, x) => sum + (x.deletions ?? 0), 0), files: diffs.length, }, }) @@ -138,7 +139,7 @@ export namespace SessionSummary { ) const target = messages.find((m) => m.info.id === input.messageID) if (!target || target.info.role !== "user") return - const msgDiffs = yield* computeDiff({ messages }) + const msgDiffs = sanitizeSensitiveDiffs(yield* computeDiff({ messages })) as Snapshot.FileDiff[] target.info.summary = { ...target.info.summary, diffs: msgDiffs } yield* sessions.updateMessage(target.info) }) @@ -147,12 +148,12 @@ export namespace SessionSummary { const diffs = yield* storage .read(["session_diff", input.sessionID]) .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[]))) - const next = diffs.map((item) => { + const next = (sanitizeSensitiveDiffs(diffs) as Snapshot.FileDiff[]).map((item) => { const file = unquoteGitPath(item.file) if (file === item.file) return item return { ...item, file } }) - const changed = next.some((item, i) => item.file !== diffs[i]?.file) + const changed = JSON.stringify(next) !== JSON.stringify(diffs) if (changed) yield* storage.write(["session_diff", input.sessionID], next).pipe(Effect.ignore) return next }) diff --git a/packages/opencode/src/session/turn-change.ts b/packages/opencode/src/session/turn-change.ts new file mode 100644 index 000000000..a3ea98e6e --- /dev/null +++ b/packages/opencode/src/session/turn-change.ts @@ -0,0 +1,423 @@ +import crypto from "crypto" +import fs from "fs/promises" +import path from "path" +import { createTwoFilesPatch, diffLines } from "diff" +import z from "zod" +import { eq, and, ne, Database } from "@/storage/db" +import { MessageID, SessionID } from "./schema" +import { TurnChangeDisplayTable, TurnChangeRestoreTable } from "./session.sql" +import { Instance } from "@/project/instance" +import { isSensitivePath } from "@/tool/sensitive" +import { trimDiff } from "@/tool/edit" + +type FileState = { exists: false; content?: undefined } | { exists: true; content: string } +type Status = "added" | "modified" | "deleted" + +export type DisplayFile = { + path: string + openPath?: string + status: Status + additions?: number + deletions?: number + patch?: string + sensitive?: boolean + binary?: boolean + large?: boolean + expandable: boolean +} + +export type Display = { + sessionID: SessionID + turnID: MessageID + messageID: MessageID + undoAvailable: boolean + redoAvailable: boolean + files: DisplayFile[] +} + +type RestoreFile = { + path: string + displayPath: string + before: FileState + after: FileState +} + +type RestoreRow = { + session_id: SessionID + message_id: MessageID + file_path: string + position: number + data: RestoreFile + finalized: boolean +} + +type MutationResult = + | { status: "applied"; display: Display } + | { + status: "blocked" + reason: "conflict" | "restore_missing" | "permission_denied" | "unsupported_size" | "write_failed" + files: Array<{ path: string; reason: string }> + } + +const DISPLAY_LIMIT = 2 * 1024 * 1024 +const RESTORE_LIMIT = 2 * 1024 * 1024 +const MAX_FILES = 200 + +function now() { + return Date.now() +} + +function hash(state: FileState) { + if (!state.exists) return "missing" + return "sha256:" + crypto.createHash("sha256").update(state.content).digest("hex") +} + +function same(a: FileState, b: FileState) { + return hash(a) === hash(b) +} + +function status(before: FileState, after: FileState): Status { + if (!before.exists && after.exists) return "added" + if (before.exists && !after.exists) return "deleted" + return "modified" +} + +function displayPath(file: string) { + const directory = Instance.directory + if (file.startsWith(directory + path.sep) || file === directory) + return path.relative(directory, file).replaceAll("\\", "/") + const worktree = Instance.worktree + if (file.startsWith(worktree + path.sep) || file === worktree) return path.relative(worktree, file).replaceAll("\\", "/") + const home = process.env.HOME + if (home && (file === home || file.startsWith(home + path.sep))) return `~/${path.relative(home, file).replaceAll("\\", "/")}` + return file +} + +function additionsDeletions(before: string, after: string) { + let additions = 0 + let deletions = 0 + for (const change of diffLines(before, after)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + return { additions, deletions } +} + +function isBinary(text: string) { + return text.includes("\0") +} + +function toDisplay(file: RestoreFile): DisplayFile | undefined { + if (same(file.before, file.after)) return + const currentStatus = status(file.before, file.after) + const sensitive = isSensitivePath(file.path) + if (sensitive) { + return { + path: file.displayPath, + status: currentStatus, + sensitive: true, + expandable: false, + } + } + + const beforeText = file.before.exists ? file.before.content : "" + const afterText = file.after.exists ? file.after.content : "" + const binary = isBinary(beforeText) || isBinary(afterText) + const large = beforeText.length > DISPLAY_LIMIT || afterText.length > DISPLAY_LIMIT + if (binary || large) { + return { + path: file.displayPath, + status: currentStatus, + ...(binary ? { binary: true } : {}), + ...(large ? { large: true } : {}), + expandable: false, + } + } + + const counts = additionsDeletions(beforeText, afterText) + return { + path: file.displayPath, + status: currentStatus, + ...counts, + patch: trimDiff(createTwoFilesPatch(file.displayPath, file.displayPath, beforeText, afterText, undefined, undefined, { context: 3 })), + expandable: true, + } +} + +function rows(sessionID: SessionID, messageID: MessageID) { + return Database.use((db) => + db + .select() + .from(TurnChangeRestoreTable) + .where(and(eq(TurnChangeRestoreTable.session_id, sessionID), eq(TurnChangeRestoreTable.message_id, messageID))) + .orderBy(TurnChangeRestoreTable.position) + .all(), + ) as RestoreRow[] +} + +function displayRow(sessionID: SessionID, messageID: MessageID) { + return Database.use((db) => + db + .select() + .from(TurnChangeDisplayTable) + .where(and(eq(TurnChangeDisplayTable.session_id, sessionID), eq(TurnChangeDisplayTable.message_id, messageID))) + .get(), + ) as { data: Display; state: "applied" | "undone" | "redo_invalidated" } | undefined +} + +async function currentState(file: string): Promise { + try { + return { exists: true, content: await fs.readFile(file, "utf-8") } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return { exists: false } + throw err + } +} + +async function applyState(file: string, state: FileState) { + if (!state.exists) { + await fs.rm(file, { force: true }) + return + } + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, state.content, "utf-8") +} + +function withAvailability(display: Display, state: "applied" | "undone" | "redo_invalidated") { + return { + ...display, + undoAvailable: state === "applied", + redoAvailable: state === "undone", + } +} + +function withOpenPaths(display: Display, restore: RestoreRow[]) { + const paths = new Map(restore.map((row) => [row.data.displayPath, row.data.path])) + return { + ...display, + files: display.files.map((file) => ({ + ...file, + openPath: paths.get(file.path), + })), + } +} + +export namespace TurnChange { + export const DisplayFileSchema = z.object({ + path: z.string(), + openPath: z.string().optional(), + status: z.enum(["added", "modified", "deleted"]), + additions: z.number().optional(), + deletions: z.number().optional(), + patch: z.string().optional(), + sensitive: z.boolean().optional(), + binary: z.boolean().optional(), + large: z.boolean().optional(), + expandable: z.boolean(), + }) + + export const DisplaySchema = z.object({ + sessionID: SessionID.zod, + turnID: MessageID.zod, + messageID: MessageID.zod, + undoAvailable: z.boolean(), + redoAvailable: z.boolean(), + files: z.array(DisplayFileSchema), + }) + + export const MutationResultSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("applied"), + display: DisplaySchema, + }), + z.object({ + status: z.literal("blocked"), + reason: z.enum(["conflict", "restore_missing", "permission_denied", "unsupported_size", "write_failed"]), + files: z.array( + z.object({ + path: z.string(), + reason: z.string(), + }), + ), + }), + ]) + + export function recordWrite(input: { + sessionID: SessionID + messageID: MessageID + path: string + before: FileState + after: FileState + }) { + if (!input.messageID) return + if (input.before.exists && input.before.content.length > RESTORE_LIMIT) return + if (input.after.exists && input.after.content.length > RESTORE_LIMIT) return + const existing = Database.use((db) => + db + .select() + .from(TurnChangeRestoreTable) + .where( + and( + eq(TurnChangeRestoreTable.session_id, input.sessionID), + eq(TurnChangeRestoreTable.message_id, input.messageID), + eq(TurnChangeRestoreTable.file_path, input.path), + ), + ) + .get(), + ) as RestoreRow | undefined + + const time = now() + const data: RestoreFile = { + path: input.path, + displayPath: displayPath(input.path), + before: existing?.data.before ?? input.before, + after: input.after, + } + if (existing) { + Database.use((db) => + db + .update(TurnChangeRestoreTable) + .set({ data, time_updated: time }) + .where( + and( + eq(TurnChangeRestoreTable.session_id, input.sessionID), + eq(TurnChangeRestoreTable.message_id, input.messageID), + eq(TurnChangeRestoreTable.file_path, input.path), + ), + ) + .run(), + ) + return + } + + const count = rows(input.sessionID, input.messageID).length + if (count >= MAX_FILES) return + Database.use((db) => + db + .insert(TurnChangeRestoreTable) + .values({ + session_id: input.sessionID, + message_id: input.messageID, + file_path: input.path, + position: count, + data, + finalized: false, + time_created: time, + time_updated: time, + }) + .run(), + ) + } + + export function finalize(input: { sessionID: SessionID; messageID: MessageID }) { + const files = rows(input.sessionID, input.messageID) + if (!files.length) return + const displayFiles = files.map((row) => toDisplay(row.data)).filter(Boolean) as DisplayFile[] + if (!displayFiles.length) return + const time = now() + const display: Display = { + sessionID: input.sessionID, + turnID: input.messageID, + messageID: input.messageID, + undoAvailable: true, + redoAvailable: false, + files: displayFiles, + } + Database.transaction((db) => { + db + .update(TurnChangeDisplayTable) + .set({ state: "redo_invalidated", time_updated: time }) + .where( + and( + eq(TurnChangeDisplayTable.session_id, input.sessionID), + ne(TurnChangeDisplayTable.message_id, input.messageID), + eq(TurnChangeDisplayTable.state, "undone"), + ), + ) + .run() + db + .insert(TurnChangeDisplayTable) + .values({ + session_id: input.sessionID, + message_id: input.messageID, + data: display, + state: "applied", + time_created: time, + time_updated: time, + }) + .onConflictDoUpdate({ + target: [TurnChangeDisplayTable.session_id, TurnChangeDisplayTable.message_id], + set: { data: display, state: "applied", time_updated: time }, + }) + .run() + db + .update(TurnChangeRestoreTable) + .set({ finalized: true, time_updated: time }) + .where( + and( + eq(TurnChangeRestoreTable.session_id, input.sessionID), + eq(TurnChangeRestoreTable.message_id, input.messageID), + ), + ) + .run() + }) + return display + } + + export function get(input: { sessionID: SessionID; messageID: MessageID }) { + const row = displayRow(input.sessionID, input.messageID) + if (!row) return + return withOpenPaths(withAvailability(row.data, row.state), rows(input.sessionID, input.messageID)) + } + + async function mutate(input: { sessionID: SessionID; messageID: MessageID; mode: "undo" | "redo" }): Promise { + const display = displayRow(input.sessionID, input.messageID) + if (!display) return { status: "blocked", reason: "restore_missing", files: [] } + const sourceState = input.mode === "undo" ? "applied" : "undone" + if (display.state !== sourceState) return { status: "blocked", reason: "conflict", files: [] } + const restore = rows(input.sessionID, input.messageID) + if (!restore.length) return { status: "blocked", reason: "restore_missing", files: [] } + + const blocked: Array<{ path: string; reason: string }> = [] + for (const row of restore) { + const expected = input.mode === "undo" ? row.data.after : row.data.before + const current = await currentState(row.data.path) + if (!same(current, expected)) blocked.push({ path: row.data.displayPath, reason: "changed" }) + } + if (blocked.length) return { status: "blocked", reason: "conflict", files: blocked } + + const rollback: Array<{ file: string; state: FileState }> = [] + try { + for (const row of restore) { + rollback.push({ file: row.data.path, state: await currentState(row.data.path) }) + await applyState(row.data.path, input.mode === "undo" ? row.data.before : row.data.after) + } + } catch { + for (const item of rollback.reverse()) { + await applyState(item.file, item.state).catch(() => undefined) + } + return { status: "blocked", reason: "write_failed", files: rollback.map((item) => ({ path: displayPath(item.file), reason: "rollback" })) } + } + + const nextState = input.mode === "undo" ? "undone" : "applied" + const persistedDisplay = withAvailability(display.data, nextState) + const nextDisplay = withOpenPaths(persistedDisplay, restore) + const time = now() + Database.use((db) => + db + .update(TurnChangeDisplayTable) + .set({ state: nextState, data: persistedDisplay, time_updated: time }) + .where(and(eq(TurnChangeDisplayTable.session_id, input.sessionID), eq(TurnChangeDisplayTable.message_id, input.messageID))) + .run(), + ) + return { status: "applied", display: nextDisplay } + } + + export function undo(input: { sessionID: SessionID; messageID: MessageID }) { + return mutate({ ...input, mode: "undo" }) + } + + export function redo(input: { sessionID: SessionID; messageID: MessageID }) { + return mutate({ ...input, mode: "redo" }) + } +} diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 1bfbfe658..513dfc53c 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -14,6 +14,7 @@ import { Config } from "@/config/config" import { Log } from "@opencode-ai/core/util/log" import { SessionShareTable } from "./share.sql" import { ShareRuntime } from "./runtime" +import { sanitizeSensitiveDiffs, sanitizeSensitiveToolPart } from "@/tool/sensitive" export namespace ShareNext { const log = Log.create({ service: "share-next" }) @@ -193,10 +194,14 @@ export namespace ShareNext { }), ) yield* watch(MessageV2.Event.PartUpdated, (evt) => - sync(evt.properties.part.sessionID, [{ type: "part", data: evt.properties.part }]), + sync(evt.properties.part.sessionID, [ + { type: "part", data: sanitizeSensitiveToolPart(evt.properties.part) as SDK.Part }, + ]), ) yield* watch(Session.Event.Diff, (evt) => - sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), + sync(evt.properties.sessionID, [ + { type: "session_diff", data: sanitizeSensitiveDiffs(evt.properties.diff) as SDK.SnapshotFileDiff[] }, + ]), ) yield* watch(Session.Event.Deleted, (evt) => remove(evt.properties.sessionID)) @@ -274,8 +279,10 @@ export namespace ShareNext { yield* sync(sessionID, [ { type: "session", data: info }, ...messages.map((item) => ({ type: "message" as const, data: item.info })), - ...messages.flatMap((item) => item.parts.map((part) => ({ type: "part" as const, data: part }))), - { type: "session_diff", data: diffs }, + ...messages.flatMap((item) => + item.parts.map((part) => ({ type: "part" as const, data: sanitizeSensitiveToolPart(part) as SDK.Part })), + ), + { type: "session_diff", data: sanitizeSensitiveDiffs(diffs) as SDK.SnapshotFileDiff[] }, { type: "model", data: models }, ]) }) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index feea42413..52f7d0bf8 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -14,11 +14,26 @@ import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" import { Format } from "../format" import * as Bom from "@/util/bom" +import { isSensitivePath, type SensitiveStatus } from "./sensitive" +import { TurnChange } from "@/session/turn-change" export const Parameters = Schema.Struct({ patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }), }) +function statusFromPatchType(type: "add" | "update" | "delete" | "move"): SensitiveStatus { + if (type === "add") return "added" + if (type === "delete") return "deleted" + return "modified" +} + +function safeTotalDiff(changes: Array<{ diff: string; sensitive: boolean }>) { + return changes + .filter((change) => !change.sensitive) + .map((change) => change.diff + (change.diff.endsWith("\n") ? "" : "\n")) + .join("") +} + export const ApplyPatchTool = Tool.define( "apply_patch", Effect.gen(function* () { @@ -63,6 +78,7 @@ export const ApplyPatchTool = Tool.define( additions: number deletions: number bom: boolean + sensitive: boolean }> = [] let totalDiff = "" @@ -95,6 +111,7 @@ export const ApplyPatchTool = Tool.define( additions, deletions, bom: next.bom, + sensitive: isSensitivePath(filePath), }) totalDiff += diff + "\n" @@ -146,6 +163,7 @@ export const ApplyPatchTool = Tool.define( additions, deletions, bom, + sensitive: isSensitivePath(filePath) || (movePath ? isSensitivePath(movePath) : false), }) totalDiff += diff + "\n" @@ -176,6 +194,7 @@ export const ApplyPatchTool = Tool.define( additions: 0, deletions, bom: source.bom, + sensitive: isSensitivePath(filePath), }) totalDiff += deleteDiff + "\n" @@ -185,14 +204,22 @@ export const ApplyPatchTool = Tool.define( } // Build per-file metadata for UI rendering (used for both permission and result) - const files = fileChanges.map((change) => ({ + const files: Array> = fileChanges.map((change) => ({ filePath: change.filePath, relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), type: change.type, - patch: change.diff, - additions: change.additions, - deletions: change.deletions, - movePath: change.movePath, + ...(change.sensitive + ? { + status: statusFromPatchType(change.type), + sensitive: true, + ...(change.movePath ? { movePath: change.movePath } : {}), + } + : { + patch: change.diff, + additions: change.additions, + deletions: change.deletions, + movePath: change.movePath, + }), })) // Check permissions — include `movePath` so a `move` hunk can't relocate @@ -214,7 +241,7 @@ export const ApplyPatchTool = Tool.define( always: ["*"], metadata: { filepath: relativePaths.join(", "), - diff: totalDiff, + ...(safeTotalDiff(fileChanges) ? { diff: safeTotalDiff(fileChanges) } : {}), files, }, }) @@ -276,6 +303,31 @@ export const ApplyPatchTool = Tool.define( } yield* bus.publish(File.Event.Edited, { file: edited }) } + + if (change.type === "move" && change.movePath) { + TurnChange.recordWrite({ + sessionID: ctx.sessionID, + messageID: ctx.messageID, + path: change.filePath, + before: { exists: true, content: change.oldContent }, + after: { exists: false }, + }) + TurnChange.recordWrite({ + sessionID: ctx.sessionID, + messageID: ctx.messageID, + path: change.movePath, + before: { exists: false }, + after: { exists: true, content: change.newContent }, + }) + } else { + TurnChange.recordWrite({ + sessionID: ctx.sessionID, + messageID: ctx.messageID, + path: change.filePath, + before: change.type === "add" ? { exists: false } : { exists: true, content: change.oldContent }, + after: change.type === "delete" ? { exists: false } : { exists: true, content: change.newContent }, + }) + } } // Rebuild the aggregated diff and per-file metadata so the caller-visible @@ -285,7 +337,7 @@ export const ApplyPatchTool = Tool.define( const c = fileChanges[i]! totalDiff += c.diff + (c.diff.endsWith("\n") ? "" : "\n") const f = files[i] - if (f) { + if (f && !c.sensitive) { f.patch = c.diff f.additions = c.additions f.deletions = c.deletions @@ -330,7 +382,7 @@ export const ApplyPatchTool = Tool.define( return { title: output, metadata: { - diff: totalDiff, + ...(safeTotalDiff(fileChanges) ? { diff: safeTotalDiff(fileChanges) } : {}), files, diagnostics, }, diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 7ce755c03..a8f3bde60 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -18,6 +18,8 @@ import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" import { AppFileSystem } from "@opencode-ai/core/filesystem" import * as Bom from "@/util/bom" +import { isSensitivePath, safeFileMetadata, safeFilepathMetadata } from "./sensitive" +import { TurnChange } from "@/session/turn-change" function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") @@ -84,10 +86,13 @@ export const EditTool = Tool.define( let diff = "" let contentOld = "" let contentNew = "" + let existedBefore = true + let bomDiscarded = false yield* lock(filePath).withPermits(1)( Effect.gen(function* () { if (params.oldString === "") { const existed = yield* afs.existsSafe(filePath) + existedBefore = existed // Reject directory targets up front instead of failing inside // Bom.readFile / writeWithDirs with an opaque internal error. if (existed) { @@ -109,18 +114,23 @@ export const EditTool = Tool.define( // BOM-less scripts. const desiredBom = source.bom const bomChanged = source.bom !== next.bom + bomDiscarded = bomChanged contentOld = source.text contentNew = next.text diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + const sensitive = isSensitivePath(filePath) + const status = existed ? "modified" : "added" yield* ctx.ask({ permission: "edit", patterns: [path.relative(Instance.worktree, filePath)], always: ["*"], - metadata: { - filepath: filePath, - diff, - ...(bomChanged && { bomDiscarded: true }), - }, + metadata: sensitive + ? safeFilepathMetadata(filePath, status, bomChanged ? { bomDiscarded: true } : undefined) + : { + filepath: filePath, + diff, + ...(bomChanged && { bomDiscarded: true }), + }, }) yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom)) if (yield* format.file(filePath)) { @@ -146,6 +156,7 @@ export const EditTool = Tool.define( ) if (!info) throw new Error(`File ${filePath} not found`) if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) + existedBefore = true const source = yield* Bom.readFile(afs, filePath) contentOld = source.text @@ -159,6 +170,7 @@ export const EditTool = Tool.define( // surface that byte-level change. const desiredBom = source.bom const bomChanged = source.bom !== next.bom + bomDiscarded = bomChanged contentNew = next.text diff = trimDiff( @@ -169,15 +181,18 @@ export const EditTool = Tool.define( normalizeLineEndings(contentNew), ), ) + const sensitive = isSensitivePath(filePath) yield* ctx.ask({ permission: "edit", patterns: [path.relative(Instance.worktree, filePath)], always: ["*"], - metadata: { - filepath: filePath, - diff, - ...(bomChanged && { bomDiscarded: true }), - }, + metadata: sensitive + ? safeFilepathMetadata(filePath, "modified", bomChanged ? { bomDiscarded: true } : undefined) + : { + filepath: filePath, + diff, + ...(bomChanged && { bomDiscarded: true }), + }, }) yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom)) @@ -206,20 +221,32 @@ export const EditTool = Tool.define( if (change.added) additions += change.count || 0 if (change.removed) deletions += change.count || 0 } - const filediff: Snapshot.FileDiff = { - file: filePath, - patch: diff, - additions, - deletions, - } + const sensitive = isSensitivePath(filePath) + const status = existedBefore ? "modified" : "added" + const filediff: Snapshot.FileDiff = sensitive + ? (safeFileMetadata(filePath, status) as unknown as Snapshot.FileDiff) + : { + file: filePath, + patch: diff, + additions, + deletions, + } yield* ctx.metadata({ metadata: { - diff, + ...(sensitive ? {} : { diff }), filediff, diagnostics: {}, + ...(sensitive && bomDiscarded ? { bomDiscarded: true } : {}), }, }) + TurnChange.recordWrite({ + sessionID: ctx.sessionID, + messageID: ctx.messageID, + path: filePath, + before: existedBefore ? { exists: true, content: contentOld } : { exists: false }, + after: { exists: true, content: contentNew }, + }) let output = "Edit applied successfully." yield* lsp.touchFile(filePath, true) @@ -231,8 +258,9 @@ export const EditTool = Tool.define( return { metadata: { diagnostics, - diff, + ...(sensitive ? {} : { diff }), filediff, + ...(sensitive && bomDiscarded ? { bomDiscarded: true } : {}), }, title: `${path.relative(Instance.worktree, filePath)}`, output, diff --git a/packages/opencode/src/tool/sensitive.ts b/packages/opencode/src/tool/sensitive.ts new file mode 100644 index 000000000..21ff4564d --- /dev/null +++ b/packages/opencode/src/tool/sensitive.ts @@ -0,0 +1,164 @@ +import path from "path" + +export type SensitiveStatus = "added" | "modified" | "deleted" + +const SENSITIVE_SUBSTRINGS = ["credential", "credentials", "secret", "token", "private-key"] + +export function isSensitivePath(filePath: string) { + const normalized = filePath.replaceAll("\\", "/").toLowerCase() + const segments = normalized.split("/").filter(Boolean) + const basename = path.posix.basename(normalized) + + if (basename === ".env" || basename.startsWith(".env.")) return true + if (basename.endsWith(".pem") || basename.endsWith(".key")) return true + + return segments.some((segment) => SENSITIVE_SUBSTRINGS.some((pattern) => segment.includes(pattern))) +} + +export function safeFileMetadata(file: string, status: SensitiveStatus) { + return { + file, + status, + sensitive: true, + } +} + +export function safeFilepathMetadata(filepath: string, status: SensitiveStatus, extra?: Record) { + return { + filepath, + status, + sensitive: true, + ...extra, + } +} + +function statusFromType(type: unknown, fallback: unknown): SensitiveStatus { + if (fallback === "added" || fallback === "deleted" || fallback === "modified") return fallback + if (type === "add") return "added" + if (type === "delete") return "deleted" + return "modified" +} + +function object(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined +} + +function fileFrom(value: Record) { + return value.file ?? value.filePath ?? value.filepath ?? value.relativePath +} + +function isSensitiveMetadata(value: unknown): boolean { + const meta = object(value) + if (!meta) return false + if (meta.sensitive === true) return true + const file = fileFrom(meta) + if (typeof file === "string" && isSensitivePath(file)) return true + if (Array.isArray(meta.files) && meta.files.some(isSensitiveMetadata)) return true + if (isSensitiveMetadata(meta.filediff)) return true + return false +} + +function sensitivePatchPaths(patchText: string) { + const paths: string[] = [] + for (const match of patchText.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)) { + paths.push(match[1]!.trim()) + } + for (const match of patchText.matchAll(/^\*\*\* Move to: (.+)$/gm)) { + paths.push(match[1]!.trim()) + } + return paths.filter(isSensitivePath) +} + +function isSensitiveInput(value: unknown): boolean { + const input = object(value) + if (!input) return false + if (typeof input.filePath === "string" && isSensitivePath(input.filePath)) return true + if (typeof input.patchText === "string" && sensitivePatchPaths(input.patchText).length > 0) return true + return false +} + +export function sanitizeSensitiveDiffs(diffs: T[]) { + return diffs.map((item) => + isSensitivePath(item.file) + ? { + file: item.file, + status: item.status ?? "modified", + sensitive: true, + } + : item, + ) +} + +export function sanitizeSensitiveFileEntry(value: unknown) { + const item = object(value) + if (!item) return value + const file = fileFrom(item) + const sensitive = item.sensitive === true || (typeof file === "string" && isSensitivePath(file)) + if (!sensitive) return value + + return { + ...(typeof item.filePath === "string" ? { filePath: item.filePath } : {}), + ...(typeof item.file === "string" ? { file: item.file } : {}), + ...(typeof item.filepath === "string" ? { filepath: item.filepath } : {}), + ...(typeof item.relativePath === "string" ? { relativePath: item.relativePath } : {}), + ...(typeof item.type === "string" ? { type: item.type } : {}), + ...(typeof item.movePath === "string" ? { movePath: item.movePath } : {}), + status: statusFromType(item.type, item.status), + sensitive: true, + } +} + +export function sanitizeSensitiveToolMetadata(metadata: unknown, input?: unknown) { + const meta = object(metadata) + if (!meta) return metadata + const sensitive = isSensitiveMetadata(meta) || isSensitiveInput(input) + if (!sensitive) return metadata + + const file = typeof meta.filepath === "string" ? meta.filepath : typeof meta.file === "string" ? meta.file : undefined + const next: Record = {} + if (file) next[typeof meta.filepath === "string" ? "filepath" : "file"] = file + if (Array.isArray(meta.files)) next.files = meta.files.map(sanitizeSensitiveFileEntry) + if (meta.filediff) next.filediff = sanitizeSensitiveFileEntry(meta.filediff) + if (meta.diagnostics !== undefined) next.diagnostics = {} + if (meta.bomDiscarded === true) next.bomDiscarded = true + next.status = statusFromType(meta.type, meta.status) + next.sensitive = true + return next +} + +export function sanitizeSensitiveToolInput(tool: string, input: unknown, metadata?: unknown) { + const value = object(input) + if (!value) return input + const sensitive = isSensitiveInput(value) || isSensitiveMetadata(metadata) + if (!sensitive) return input + + if (typeof value.filePath === "string") { + return { + filePath: value.filePath, + sensitive: true, + } + } + if (tool === "apply_patch" && typeof value.patchText === "string") { + return { + files: sensitivePatchPaths(value.patchText).map((file) => ({ file, status: "modified", sensitive: true })), + sensitive: true, + } + } + return { sensitive: true } +} + +export function sanitizeSensitiveToolPart(part: T): T { + if (part.type !== "tool" || !part.state || !part.tool) return part + if (!("input" in part.state) && !("metadata" in part.state)) return part + const input = sanitizeSensitiveToolInput(part.tool, part.state.input, part.state.metadata) + const metadata = sanitizeSensitiveToolMetadata(part.state.metadata, part.state.input) + if (input === part.state.input && metadata === part.state.metadata) return part + return { + ...part, + state: { + ...part.state, + input, + metadata, + }, + } +} diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 51ec02a25..fab89f304 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -14,6 +14,8 @@ import { Instance } from "../project/instance" import { trimDiff } from "./edit" import { assertExternalDirectoryEffect } from "./external-directory" import * as Bom from "@/util/bom" +import { isSensitivePath, safeFilepathMetadata } from "./sensitive" +import { TurnChange } from "@/session/turn-change" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -55,15 +57,19 @@ export const WriteTool = Tool.define( const contentNew = next.text let diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew)) + const sensitive = isSensitivePath(filepath) + const status = exists ? "modified" : "added" yield* ctx.ask({ permission: "edit", patterns: [path.relative(Instance.worktree, filepath)], always: ["*"], - metadata: { - filepath, - diff, - ...(bomChanged && { bomDiscarded: true }), - }, + metadata: sensitive + ? safeFilepathMetadata(filepath, status, bomChanged ? { bomDiscarded: true } : undefined) + : { + filepath, + diff, + ...(bomChanged && { bomDiscarded: true }), + }, }) yield* fs.writeWithDirs(filepath, Bom.join(contentNew, desiredBom)) @@ -76,6 +82,13 @@ export const WriteTool = Tool.define( diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, finalContent)) } yield* bus.publish(File.Event.Edited, { file: filepath }) + TurnChange.recordWrite({ + sessionID: ctx.sessionID, + messageID: ctx.messageID, + path: filepath, + before: exists ? { exists: true, content: contentOld } : { exists: false }, + after: { exists: true, content: finalContent }, + }) yield* bus.publish(FileWatcher.Event.Updated, { file: filepath, event: exists ? "change" : "add", diff --git a/packages/opencode/test/session/export.test.ts b/packages/opencode/test/session/export.test.ts index b8d9ab1c9..cce7e4713 100644 --- a/packages/opencode/test/session/export.test.ts +++ b/packages/opencode/test/session/export.test.ts @@ -180,6 +180,56 @@ describe("Export.session", () => { }) }) +describe("Export.redactPart sensitive tool metadata", () => { + test("redacts sensitive tool input and metadata", () => { + const part: MessageV2.ToolPart = { + id: PartID.make("prt_sensitive_tool"), + messageID: MessageID.make("msg_sensitive_tool"), + sessionID: SessionID.make("ses_sensitive_tool"), + type: "tool", + tool: "edit", + callID: "call_sensitive_tool", + state: { + status: "completed", + input: { + filePath: "/tmp/project/.env", + oldString: "TOKEN=old-secret", + newString: "TOKEN=new-secret", + }, + output: "Edit applied successfully.", + title: ".env", + metadata: { + diff: "@@\n-TOKEN=old-secret\n+TOKEN=new-secret\n", + filediff: { + file: "/tmp/project/.env", + patch: "@@\n-TOKEN=old-secret\n+TOKEN=new-secret\n", + additions: 1, + deletions: 1, + }, + }, + time: { start: 1, end: 2 }, + }, + } + + const redacted = redactPart(part, { count: { omitted: 0 } }) + const serialized = JSON.stringify(redacted) + + expect(serialized).not.toContain("old-secret") + expect(serialized).not.toContain("new-secret") + expect(serialized).not.toContain("@@") + expect((redacted as MessageV2.ToolPart).state).toMatchObject({ + input: { filePath: "/tmp/project/.env", sensitive: true }, + metadata: { + filediff: { + file: "/tmp/project/.env", + status: "modified", + sensitive: true, + }, + }, + }) + }) +}) + describe("Export.deriveSnapshotDiagnostics", () => { const sessionID = SessionID.make("ses_diag") const messageID = MessageID.make("msg_assistant") diff --git a/packages/opencode/test/session/session-artifacts.test.ts b/packages/opencode/test/session/session-artifacts.test.ts index a92dd9f8d..5a4b8b9d6 100644 --- a/packages/opencode/test/session/session-artifacts.test.ts +++ b/packages/opencode/test/session/session-artifacts.test.ts @@ -2,10 +2,13 @@ import { describe, expect, test } from "bun:test" import { Instance } from "../../src/project/instance" import { ModelID, ProviderID } from "../../src/provider/schema" import { Session } from "../../src/session" +import { AppRuntime } from "../../src/effect/app-runtime" +import { Storage } from "../../src/storage/storage" import { MessageV2 } from "../../src/session/message-v2" import { MessageID } from "../../src/session/schema" import { SessionSummary } from "../../src/session/summary" import { tmpdir } from "../fixture/fixture" +import { Effect } from "effect" describe("session artifacts", () => { test("keeps files that were added in earlier turns even if later turns delete them", async () => { @@ -71,4 +74,46 @@ describe("session artifacts", () => { }, }) }) + + test("redacts sensitive stored session diffs before returning or rewriting them", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Sensitive diff" }) + const rawDiff = [ + { + file: ".env", + patch: "@@\n-TOKEN=old-secret\n+TOKEN=new-secret\n", + additions: 1, + deletions: 1, + status: "modified" as const, + }, + ] + + await AppRuntime.runPromise( + Effect.gen(function* () { + const storage = yield* Storage.Service + yield* storage.write(["session_diff", session.id], rawDiff) + }), + ) + + const diff = await SessionSummary.diff({ sessionID: session.id }) + const serialized = JSON.stringify(diff) + + expect(serialized).not.toContain("old-secret") + expect(serialized).not.toContain("new-secret") + expect(diff as unknown).toEqual([{ file: ".env", status: "modified", sensitive: true }]) + + const stored = await AppRuntime.runPromise( + Effect.gen(function* () { + const storage = yield* Storage.Service + return yield* storage.read(["session_diff", session.id]) + }), + ) + expect(stored).toEqual(diff) + }, + }) + }) }) diff --git a/packages/opencode/test/session/turn-change.test.ts b/packages/opencode/test/session/turn-change.test.ts new file mode 100644 index 000000000..fc33e835c --- /dev/null +++ b/packages/opencode/test/session/turn-change.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Session as SessionNs } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { TurnChange } from "../../src/session/turn-change" +import { MessageID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" + +const messageID = MessageID.make("msg_turn_change") + +async function createTurn() { + const session = await SessionNs.create({ title: "turn change" }) + await SessionNs.updateMessage({ + id: messageID, + sessionID: session.id, + role: "assistant", + parentID: MessageID.make("msg_user"), + time: { created: Date.now() }, + modelID: ModelID.make("test"), + providerID: ProviderID.make("test"), + mode: "", + agent: "build", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as unknown as MessageV2.Info) + return { sessionID: session.id, messageID } +} + +describe("TurnChange", () => { + test("finalizes the net change from first before state to latest after state", async () => { + await resetDatabase() + await using fixture = await tmpdir() + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const turn = await createTurn() + const target = path.join(fixture.path, "file.txt") + await fs.writeFile(target, "one\n", "utf-8") + + TurnChange.recordWrite({ + ...turn, + path: target, + before: { exists: true, content: "one\n" }, + after: { exists: true, content: "two\n" }, + }) + TurnChange.recordWrite({ + ...turn, + path: target, + before: { exists: true, content: "two\n" }, + after: { exists: true, content: "three\n" }, + }) + + const display = TurnChange.finalize(turn) + + expect(display?.files).toHaveLength(1) + expect(display?.files[0]).toMatchObject({ + path: "file.txt", + status: "modified", + additions: 1, + deletions: 1, + expandable: true, + }) + expect(display?.files[0].patch).toContain("-one") + expect(display?.files[0].patch).toContain("+three") + expect(display?.files[0].patch).not.toContain("+two") + }, + }) + }) + + test("finalizes sensitive files as status-only while keeping undo available", async () => { + await resetDatabase() + await using fixture = await tmpdir() + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const turn = await createTurn() + const target = path.join(fixture.path, ".env") + + TurnChange.recordWrite({ + ...turn, + path: target, + before: { exists: false }, + after: { exists: true, content: "TOKEN=new-secret\n" }, + }) + + const display = TurnChange.finalize(turn) + const serialized = JSON.stringify(display) + + expect(display?.undoAvailable).toBe(true) + expect(display?.files).toEqual([ + { + path: ".env", + status: "added", + sensitive: true, + expandable: false, + }, + ]) + expect(serialized).not.toContain("new-secret") + expect(serialized).not.toContain("@@") + }, + }) + }) + + test("undo preflight conflict writes no files", async () => { + await resetDatabase() + await using fixture = await tmpdir() + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const turn = await createTurn() + const target = path.join(fixture.path, "file.txt") + await fs.writeFile(target, "after\n", "utf-8") + TurnChange.recordWrite({ + ...turn, + path: target, + before: { exists: true, content: "before\n" }, + after: { exists: true, content: "after\n" }, + }) + TurnChange.finalize(turn) + await fs.writeFile(target, "user edit\n", "utf-8") + + const result = await TurnChange.undo(turn) + + expect(result.status).toBe("blocked") + expect(await fs.readFile(target, "utf-8")).toBe("user edit\n") + }, + }) + }) + + test("later finalized turn invalidates previous redo", async () => { + await resetDatabase() + await using fixture = await tmpdir() + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const first = await createTurn() + const target = path.join(fixture.path, "file.txt") + await fs.writeFile(target, "after\n", "utf-8") + TurnChange.recordWrite({ + ...first, + path: target, + before: { exists: true, content: "before\n" }, + after: { exists: true, content: "after\n" }, + }) + TurnChange.finalize(first) + expect((await TurnChange.undo(first)).status).toBe("applied") + expect(TurnChange.get(first)?.redoAvailable).toBe(true) + + const second = { sessionID: first.sessionID, messageID: MessageID.make("msg_turn_change_2") } + await SessionNs.updateMessage({ + id: second.messageID, + sessionID: second.sessionID, + role: "assistant", + parentID: MessageID.make("msg_user_2"), + time: { created: Date.now() }, + modelID: ModelID.make("test"), + providerID: ProviderID.make("test"), + mode: "", + agent: "build", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as unknown as MessageV2.Info) + TurnChange.recordWrite({ + ...second, + path: target, + before: { exists: true, content: "before\n" }, + after: { exists: true, content: "new turn\n" }, + }) + TurnChange.finalize(second) + + expect(TurnChange.get(first)?.redoAvailable).toBe(false) + }, + }) + }) +}) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index b90f32172..73803e453 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -362,4 +362,72 @@ describe("ShareNext", () => { { config: { enterprise: { url: "https://legacy-share.example.com" } } }, ), ) + + it.live("ShareNext redacts sensitive session diff payloads", () => + provideTmpdirInstance( + () => { + const seen: Array<{ url: string; body: string }> = [] + const client = HttpClient.make((req) => { + if (req.url.endsWith("/sync") && req.body._tag === "Uint8Array") { + seen.push({ url: req.url, body: new TextDecoder().decode(req.body.body) }) + } + return Effect.succeed(json(req, { ok: true })) + }) + + return Effect.gen(function* () { + const bus = yield* Bus.Service + const share = yield* ShareNext.Service + const session = yield* Session.Service + + const info = yield* session.create({ title: "first" }) + yield* share.init() + yield* Effect.sleep(50) + yield* Effect.sync(() => + Database.use((db) => + db + .insert(SessionShareTable) + .values({ + session_id: info.id, + id: "shr_abc", + url: "https://legacy-share.example.com/share/abc", + secret: "sec_123", + }) + .run(), + ), + ) + + yield* bus.publish(Session.Event.Diff, { + sessionID: info.id, + diff: [ + { + file: ".env", + patch: "@@\n-TOKEN=old-secret\n+TOKEN=new-secret\n", + additions: 1, + deletions: 1, + status: "modified", + }, + ], + }) + yield* Effect.sleep(1_250) + + expect(seen).toHaveLength(1) + const body = JSON.parse(seen[0].body) as { + data: Array<{ type: string; data: Array> }> + } + const serialized = JSON.stringify(body) + + expect(serialized).not.toContain("old-secret") + expect(serialized).not.toContain("new-secret") + expect(serialized).not.toContain("@@") + expect(body.data).toEqual([ + { + type: "session_diff", + data: [{ file: ".env", status: "modified", sensitive: true }], + }, + ]) + }).pipe(Effect.provide(wired(client))) + }, + { config: { enterprise: { url: "https://legacy-share.example.com" } } }, + ), + ) }) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 549c6dd90..b75c5e350 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -39,17 +39,9 @@ type AskInput = { patterns: string[] always: string[] metadata: { - diff: string + diff?: string filepath: string - files: Array<{ - filePath: string - relativePath: string - type: "add" | "update" | "delete" | "move" - patch: string - additions: number - deletions: number - movePath?: string - }> + files: Array> } } @@ -145,6 +137,49 @@ describe("tool.apply_patch freeform", () => { }) }) + test("redacts sensitive file permission and result metadata", async () => { + await using fixture = await tmpdir({ git: true }) + const { ctx, calls } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, ".env") + await fs.writeFile(target, "TOKEN=old-secret\n", "utf-8") + + const patchText = "*** Begin Patch\n*** Update File: .env\n@@\n-TOKEN=old-secret\n+TOKEN=new-secret\n*** End Patch" + const result = await execute({ patchText }, ctx) + const serialized = JSON.stringify({ calls, result }) + + expect(calls).toHaveLength(1) + expect(calls[0].metadata).toEqual({ + filepath: ".env", + files: [ + { + filePath: target, + relativePath: ".env", + type: "update", + status: "modified", + sensitive: true, + }, + ], + }) + expect((result.metadata as any).files).toEqual([ + { + filePath: target, + relativePath: ".env", + type: "update", + status: "modified", + sensitive: true, + }, + ]) + expect(serialized).not.toContain("old-secret") + expect(serialized).not.toContain("new-secret") + expect(serialized).not.toContain("@@") + }, + }) + }) + test("permission metadata includes move file info", async () => { await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 8f6e5bfb4..0d923ba51 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -28,6 +28,26 @@ const ctx = { ask: () => Effect.void, } +function captureToolCtx() { + const asks: any[] = [] + const metadata: any[] = [] + return { + ctx: { + ...ctx, + ask: (input: any) => + Effect.sync(() => { + asks.push(input) + }), + metadata: (input: any) => + Effect.sync(() => { + metadata.push(input) + }), + }, + asks, + metadata, + } +} + afterEach(async () => { await Instance.disposeAll() }) @@ -282,6 +302,53 @@ describe("tool.edit", () => { }), ), ) + + it.live("redacts sensitive file permission and tool metadata", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, ".env") + yield* Effect.promise(() => fs.writeFile(filepath, "TOKEN=old-secret\n", "utf-8")) + const captured = captureToolCtx() + + const result = yield* run( + { + filePath: filepath, + oldString: "old-secret", + newString: "new-secret", + }, + captured.ctx, + ) + const serialized = JSON.stringify({ asks: captured.asks, metadata: captured.metadata, result }) + + expect(captured.asks).toHaveLength(1) + expect(captured.asks[0].metadata).toEqual({ + filepath, + status: "modified", + sensitive: true, + }) + expect(captured.metadata).toEqual([ + { + metadata: { + filediff: { + file: filepath, + status: "modified", + sensitive: true, + }, + diagnostics: {}, + }, + }, + ]) + expect((result.metadata as any).filediff).toEqual({ + file: filepath, + status: "modified", + sensitive: true, + }) + expect(serialized).not.toContain("old-secret") + expect(serialized).not.toContain("new-secret") + expect(serialized).not.toContain("@@") + }), + ), + ) }) describe("edge cases", () => { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 9cf8de27e..60422c1c3 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -27,6 +27,20 @@ const ctx = { ask: () => Effect.void, } +function captureAskCtx() { + const calls: any[] = [] + return { + ctx: { + ...ctx, + ask: (input: any) => + Effect.sync(() => { + calls.push(input) + }), + }, + calls, + } +} + afterEach(async () => { await Instance.disposeAll() }) @@ -145,6 +159,29 @@ describe("tool.write", () => { }), ), ) + + it.live("redacts sensitive file permission metadata", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, ".env") + yield* Effect.promise(() => fs.writeFile(filepath, "TOKEN=old-secret\n", "utf-8")) + const captured = captureAskCtx() + + const result = yield* run({ filePath: filepath, content: "TOKEN=new-secret\n" }, captured.ctx) + const serialized = JSON.stringify({ permission: captured.calls, result }) + + expect(captured.calls).toHaveLength(1) + expect(captured.calls[0].metadata).toEqual({ + filepath, + status: "modified", + sensitive: true, + }) + expect(serialized).not.toContain("old-secret") + expect(serialized).not.toContain("new-secret") + expect(serialized).not.toContain("@@") + }), + ), + ) }) describe("file permissions", () => { diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 54076f3f8..c5d4ec60a 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -93,6 +93,151 @@ min-width: 0; } + [data-slot="session-turn-changes"] { + width: 100%; + min-width: 0; + margin-top: 4px; + border: 1px solid var(--border-base); + border-radius: 8px; + background: var(--surface-base); + overflow: hidden; + } + + [data-slot="session-turn-changes-header"] { + height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 12px; + border-bottom: 1px solid var(--border-base); + } + + [data-slot="session-turn-changes-summary"] { + min-width: 0; + display: flex; + align-items: baseline; + gap: 7px; + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + line-height: var(--line-height-large); + font-weight: var(--font-weight-medium); + color: var(--text-strong); + white-space: nowrap; + } + + [data-slot="session-turn-changes-additions"] { + color: var(--text-diff-add-base); + font-variant-numeric: tabular-nums; + } + + [data-slot="session-turn-changes-deletions"] { + color: var(--text-diff-delete-base); + font-variant-numeric: tabular-nums; + } + + [data-slot="session-turn-changes-action"] { + width: 72px; + height: 28px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-weak); + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + line-height: 28px; + font-weight: var(--font-weight-regular); + cursor: pointer; + } + + [data-slot="session-turn-changes-action"]:hover { + background: var(--button-ghost-hover); + color: var(--text-strong); + } + + [data-slot="session-turn-changes-action"][data-confirm] { + color: var(--icon-warning-base); + } + + [data-slot="session-turn-changes-list"] { + display: flex; + flex-direction: column; + } + + [data-slot="session-turn-change-item"] + [data-slot="session-turn-change-item"] { + border-top: 1px solid var(--border-base); + } + + [data-slot="session-turn-change-row"] { + height: 44px; + display: grid; + grid-template-columns: 28px minmax(0, 1fr) max-content 64px; + align-items: center; + gap: 8px; + padding: 0 8px 0 10px; + } + + [data-slot="session-turn-change-row"][data-expandable] { + cursor: pointer; + } + + [data-slot="session-turn-change-row"]:hover { + background: var(--surface-base-hover); + } + + [data-slot="session-turn-change-chevron"] { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--icon-weaker); + transform: rotate(-90deg); + transition: transform 0.15s ease; + } + + [data-slot="session-turn-change-item"][data-expanded] [data-slot="session-turn-change-chevron"] { + transform: rotate(0deg); + } + + [data-slot="session-turn-change-path"] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-strong); + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + line-height: var(--line-height-large); + } + + [data-slot="session-turn-change-meta"] { + display: inline-flex; + gap: 7px; + align-items: center; + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + line-height: var(--line-height-large); + white-space: nowrap; + } + + [data-slot="session-turn-change-status"] { + color: var(--text-weak); + } + + [data-slot="session-turn-change-actions"] { + width: 64px; + display: inline-flex; + justify-content: flex-end; + gap: 4px; + } + + [data-slot="session-turn-change-diff"] { + max-height: 420px; + overflow: auto; + background: var(--surface-inset-base); + } + [data-slot="session-turn-diffs-header"] { display: flex; align-items: center; diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 66bcf1775..dc0545d9e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -10,7 +10,7 @@ import { useFileComponent } from "../context/file" import { Binary } from "@opencode-ai/core/util/binary" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" -import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part" @@ -19,6 +19,7 @@ import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { DiffChanges } from "./diff-changes" import { Icon } from "./icon" +import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" import { SessionRetry } from "./session-retry" import { TextReveal } from "./text-reveal" @@ -143,6 +144,28 @@ function heading(text: string) { } } +type TurnChangeFile = { + path: string + openPath?: string + status: "added" | "modified" | "deleted" + additions?: number + deletions?: number + patch?: string + sensitive?: boolean + binary?: boolean + large?: boolean + expandable: boolean +} + +type TurnChangeDisplay = { + sessionID: string + turnID: string + messageID: string + undoAvailable: boolean + redoAvailable: boolean + files: TurnChangeFile[] +} + export function SessionTurn( props: ParentProps<{ sessionID: string @@ -152,6 +175,13 @@ export function SessionTurn( showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean + turnChanges?: Record + turnChangeActions?: { + undo?: (messageID: string) => Promise | void + redo?: (messageID: string) => Promise | void + openFile?: (path: string) => void + showInFolder?: (path: string) => void + } active?: boolean status?: SessionStatus onUserInteracted?: () => void @@ -170,6 +200,7 @@ export function SessionTurn( const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] const emptyDiffs: SnapshotFileDiff[] = [] + const emptyTurnFiles: TurnChangeFile[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) @@ -278,6 +309,56 @@ export function SessionTurn( { equals: same }, ) + const turnChangeMessageID = createMemo(() => assistantMessages().findLast((item) => item.time.completed)?.id) + const turnChange = createMemo(() => { + const id = turnChangeMessageID() + return id ? props.turnChanges?.[id] : undefined + }) + const turnFiles = createMemo(() => turnChange()?.files ?? emptyTurnFiles) + const turnEdited = createMemo(() => turnFiles().length) + const turnAdditions = createMemo(() => turnFiles().reduce((sum, file) => sum + (file.additions ?? 0), 0)) + const turnDeletions = createMemo(() => turnFiles().reduce((sum, file) => sum + (file.deletions ?? 0), 0)) + const [turnExpanded, setTurnExpanded] = createSignal([]) + const [confirmAction, setConfirmAction] = createSignal<"undo" | "redo" | undefined>() + let confirmTimer: ReturnType | undefined + const resetConfirm = () => { + if (confirmTimer) clearTimeout(confirmTimer) + confirmTimer = undefined + setConfirmAction(undefined) + } + const primeConfirm = (action: "undo" | "redo") => { + if (confirmAction() === action) return true + setConfirmAction(action) + if (confirmTimer) clearTimeout(confirmTimer) + confirmTimer = setTimeout(resetConfirm, 3000) + return false + } + onCleanup(resetConfirm) + const mutateTurnChange = async () => { + const current = turnChange() + const id = current?.messageID + if (!id) return + const action = current.undoAvailable ? "undo" : current.redoAvailable ? "redo" : undefined + if (!action) return + if (!primeConfirm(action)) return + resetConfirm() + if (action === "undo") await props.turnChangeActions?.undo?.(id) + else await props.turnChangeActions?.redo?.(id) + } + const turnActionLabel = createMemo(() => { + const current = turnChange() + const action = current?.undoAvailable ? "undo" : current?.redoAvailable ? "redo" : undefined + if (!action) return "" + const base = action === "undo" ? "Undo" : "Redo" + return confirmAction() === action ? `${base}?` : base + }) + const parentPath = (value: string) => { + const normalized = value.replaceAll("\\", "/") + const index = normalized.lastIndexOf("/") + if (index <= 0) return value + return value.slice(0, index) + } + const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError")) const divider = createMemo(() => { if (compaction()) return i18n.t("ui.messagePart.compaction") @@ -423,7 +504,113 @@ export function SessionTurn( - 0 && !working()}> + 0 && !working()}> +
+
+
+ + {turnEdited()} {i18n.t("ui.sessionTurn.diffs.changed")}{" "} + {i18n.t(turnEdited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} + + +{turnAdditions()} + -{turnDeletions()} +
+ + + +
+
+ + {(file) => { + const expanded = createMemo(() => turnExpanded().includes(file.path)) + const toggle = () => { + if (!file.expandable) return + setTurnExpanded((current) => + current.includes(file.path) + ? current.filter((item) => item !== file.path) + : [...current, file.path], + ) + } + const view = createMemo(() => + file.patch + ? normalize({ + file: file.path, + patch: file.patch, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + status: file.status, + }) + : undefined, + ) + return ( +
+
+ + + + + + {file.path} + + Updated} + > + +{file.additions ?? 0} + -{file.deletions ?? 0} + + + event.stopPropagation()}> + file.openPath && props.turnChangeActions?.openFile?.(file.openPath)} + /> + + file.openPath && + props.turnChangeActions?.showInFolder?.( + file.status === "deleted" ? parentPath(file.openPath) : file.openPath, + ) + } + /> + +
+ + {(diff) => ( +
+ +
+ )} +
+
+ ) + }} +
+
+
+
+ 0 && !working()}>
Date: Sun, 3 May 2026 20:55:59 +0800 Subject: [PATCH 02/17] fix: harden turn change restore safety --- .../src/pages/session/message-timeline.tsx | 20 +++- .../20260503120000_turn_changes/migration.sql | 8 +- .../opencode/src/server/instance/session.ts | 52 +++++++- packages/opencode/src/session/session.sql.ts | 8 +- packages/opencode/src/session/turn-change.ts | 111 ++++++++++++++---- packages/opencode/src/share/share-next.ts | 16 ++- packages/opencode/src/tool/apply_patch.ts | 36 +++++- packages/opencode/src/tool/edit.ts | 12 +- packages/opencode/src/tool/sensitive.ts | 11 +- packages/opencode/src/tool/write.ts | 9 +- packages/opencode/test/session/export.test.ts | 1 + .../test/session/session-artifacts.test.ts | 4 +- .../opencode/test/session/turn-change.test.ts | 65 ++++++++++ .../opencode/test/share/share-next.test.ts | 84 ++++++++++++- packages/ui/src/components/session-turn.tsx | 18 ++- 15 files changed, 397 insertions(+), 58 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 83adc3700..da815e638 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -349,7 +349,12 @@ export function MessageTimeline(props: { } showToast({ title: action === "undo" ? "Undo blocked" : "Redo blocked", - description: "The file changed after this turn, so PawWork did not overwrite it.", + description: + body?.reason === "conflict" + ? "The file changed after this turn, so PawWork did not overwrite it." + : body?.reason === "unsupported_size" + ? "This turn includes a file without restore data, so PawWork did not change anything." + : "PawWork could not apply this change.", variant: "error", }) return turnChanges[messageID] ?? undefined @@ -371,9 +376,16 @@ export function MessageTimeline(props: { const key = `${id}:${message.id}` if (fetchedTurnChanges.has(key)) continue fetchedTurnChanges.add(key) - void turnChangeFetch(message.id).catch(() => { - setTurnChanges(message.id, null) - }) + void turnChangeFetch(message.id) + .then((display) => { + if (display) return + fetchedTurnChanges.delete(key) + setTimeout(() => void turnChangeFetch(message.id).catch(() => undefined), 500) + }) + .catch(() => { + fetchedTurnChanges.delete(key) + setTurnChanges(message.id, null) + }) } }, ), diff --git a/packages/opencode/migration/20260503120000_turn_changes/migration.sql b/packages/opencode/migration/20260503120000_turn_changes/migration.sql index f4d036641..034f94b40 100644 --- a/packages/opencode/migration/20260503120000_turn_changes/migration.sql +++ b/packages/opencode/migration/20260503120000_turn_changes/migration.sql @@ -9,8 +9,8 @@ CREATE TABLE `turn_change_display` ( CONSTRAINT `fk_turn_change_display_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE ); --> statement-breakpoint -CREATE UNIQUE INDEX `turn_change_display_message_idx` ON `turn_change_display` (`session_id`,`message_id`);--> statement-breakpoint -CREATE INDEX `turn_change_display_session_idx` ON `turn_change_display` (`session_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `turn_change_display_session_id_message_id_idx` ON `turn_change_display` (`session_id`,`message_id`);--> statement-breakpoint +CREATE INDEX `turn_change_display_session_id_idx` ON `turn_change_display` (`session_id`);--> statement-breakpoint CREATE TABLE `turn_change_restore` ( `session_id` text NOT NULL, `message_id` text NOT NULL, @@ -24,5 +24,5 @@ CREATE TABLE `turn_change_restore` ( CONSTRAINT `fk_turn_change_restore_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE ); --> statement-breakpoint -CREATE UNIQUE INDEX `turn_change_restore_file_idx` ON `turn_change_restore` (`session_id`,`message_id`,`file_path`);--> statement-breakpoint -CREATE INDEX `turn_change_restore_message_idx` ON `turn_change_restore` (`session_id`,`message_id`); +CREATE UNIQUE INDEX `turn_change_restore_session_id_message_id_file_path_idx` ON `turn_change_restore` (`session_id`,`message_id`,`file_path`);--> statement-breakpoint +CREATE INDEX `turn_change_restore_session_id_message_id_idx` ON `turn_change_restore` (`session_id`,`message_id`); diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 42ba0153d..0a8f08834 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -29,10 +29,38 @@ import { errors } from "../error" import { lazy } from "../../util/lazy" import { Bus } from "../../bus" import { NamedError } from "@opencode-ai/util/error" -import { TurnChange } from "@/session/turn-change" +import { TurnChange, type Display as TurnChangeDisplay } from "@/session/turn-change" +import { FileWatcher } from "@/file/watcher" +import { File } from "@/file" +import { LSP } from "@/lsp" const log = Log.create({ service: "server" }) +function publishTurnChangeFiles(display: TurnChangeDisplay, mode: "undo" | "redo") { + return Effect.gen(function* () { + const bus = yield* Bus.Service + const lsp = yield* LSP.Service + for (const file of display.files) { + if (!file.openPath) continue + yield* bus.publish(File.Event.Edited, { file: file.openPath }) + const event = + mode === "redo" + ? file.status === "added" + ? "add" + : file.status === "deleted" + ? "unlink" + : "change" + : file.status === "added" + ? "unlink" + : file.status === "deleted" + ? "add" + : "change" + yield* bus.publish(FileWatcher.Event.Updated, { file: file.openPath, event }) + if (event !== "unlink") yield* lsp.touchFile(file.openPath, true) + } + }) +} + export const SessionRoutes = lazy(() => new Hono() .get( @@ -590,7 +618,16 @@ export const SessionRoutes = lazy(() => ), async (c) => { const params = c.req.valid("param") - return c.json(await TurnChange.undo(params)) + const result = await AppRuntime.runPromise( + Effect.gen(function* () { + const state = yield* SessionRunState.Service + yield* state.assertNotBusy(params.sessionID) + const result = yield* Effect.promise(() => TurnChange.undo(params)) + if (result.status === "applied") yield* publishTurnChangeFiles(result.display, "undo") + return result + }), + ) + return c.json(result) }, ) .post( @@ -619,7 +656,16 @@ export const SessionRoutes = lazy(() => ), async (c) => { const params = c.req.valid("param") - return c.json(await TurnChange.redo(params)) + const result = await AppRuntime.runPromise( + Effect.gen(function* () { + const state = yield* SessionRunState.Service + yield* state.assertNotBusy(params.sessionID) + const result = yield* Effect.promise(() => TurnChange.redo(params)) + if (result.status === "applied") yield* publishTurnChangeFiles(result.display, "redo") + return result + }), + ) + return c.json(result) }, ) .get( diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 8e4600a6a..8a8032014 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -147,8 +147,8 @@ export const TurnChangeDisplayTable = sqliteTable( ...Timestamps, }, (table) => [ - uniqueIndex("turn_change_display_message_idx").on(table.session_id, table.message_id), - index("turn_change_display_session_idx").on(table.session_id), + uniqueIndex("turn_change_display_session_id_message_id_idx").on(table.session_id, table.message_id), + index("turn_change_display_session_id_idx").on(table.session_id), ], ) @@ -170,8 +170,8 @@ export const TurnChangeRestoreTable = sqliteTable( ...Timestamps, }, (table) => [ - uniqueIndex("turn_change_restore_file_idx").on(table.session_id, table.message_id, table.file_path), - index("turn_change_restore_message_idx").on(table.session_id, table.message_id), + uniqueIndex("turn_change_restore_session_id_message_id_file_path_idx").on(table.session_id, table.message_id, table.file_path), + index("turn_change_restore_session_id_message_id_idx").on(table.session_id, table.message_id), ], ) diff --git a/packages/opencode/src/session/turn-change.ts b/packages/opencode/src/session/turn-change.ts index a3ea98e6e..8fce1fade 100644 --- a/packages/opencode/src/session/turn-change.ts +++ b/packages/opencode/src/session/turn-change.ts @@ -4,13 +4,17 @@ import path from "path" import { createTwoFilesPatch, diffLines } from "diff" import z from "zod" import { eq, and, ne, Database } from "@/storage/db" +import { count } from "drizzle-orm" import { MessageID, SessionID } from "./schema" import { TurnChangeDisplayTable, TurnChangeRestoreTable } from "./session.sql" import { Instance } from "@/project/instance" import { isSensitivePath } from "@/tool/sensitive" import { trimDiff } from "@/tool/edit" +import { Global } from "@/global" -type FileState = { exists: false; content?: undefined } | { exists: true; content: string } +type FileState = + | { exists: false; content?: undefined; hash?: string; restorable?: boolean; bom?: boolean; large?: boolean; binary?: boolean } + | { exists: true; content?: string; hash?: string; restorable?: boolean; bom?: boolean; large?: boolean; binary?: boolean } type Status = "added" | "modified" | "deleted" export type DisplayFile = { @@ -23,6 +27,7 @@ export type DisplayFile = { sensitive?: boolean binary?: boolean large?: boolean + restoreAvailable?: boolean expandable: boolean } @@ -62,6 +67,7 @@ type MutationResult = const DISPLAY_LIMIT = 2 * 1024 * 1024 const RESTORE_LIMIT = 2 * 1024 * 1024 const MAX_FILES = 200 +const locks = new Map>() function now() { return Date.now() @@ -69,7 +75,8 @@ function now() { function hash(state: FileState) { if (!state.exists) return "missing" - return "sha256:" + crypto.createHash("sha256").update(state.content).digest("hex") + if (state.hash) return state.hash + return "sha256:" + crypto.createHash("sha256").update(state.content ?? "").digest("hex") } function same(a: FileState, b: FileState) { @@ -88,11 +95,15 @@ function displayPath(file: string) { return path.relative(directory, file).replaceAll("\\", "/") const worktree = Instance.worktree if (file.startsWith(worktree + path.sep) || file === worktree) return path.relative(worktree, file).replaceAll("\\", "/") - const home = process.env.HOME + const home = Global.Path.home if (home && (file === home || file.startsWith(home + path.sep))) return `~/${path.relative(home, file).replaceAll("\\", "/")}` return file } +function byteSize(text: string) { + return Buffer.byteLength(text, "utf8") +} + function additionsDeletions(before: string, after: string) { let additions = 0 let deletions = 0 @@ -107,6 +118,10 @@ function isBinary(text: string) { return text.includes("\0") } +function canRestore(state: FileState) { + return !state.exists || (state.content !== undefined && state.restorable !== false && !state.large && !state.binary) +} + function toDisplay(file: RestoreFile): DisplayFile | undefined { if (same(file.before, file.after)) return const currentStatus = status(file.before, file.after) @@ -120,16 +135,17 @@ function toDisplay(file: RestoreFile): DisplayFile | undefined { } } - const beforeText = file.before.exists ? file.before.content : "" - const afterText = file.after.exists ? file.after.content : "" - const binary = isBinary(beforeText) || isBinary(afterText) - const large = beforeText.length > DISPLAY_LIMIT || afterText.length > DISPLAY_LIMIT + const beforeText = file.before.exists ? (file.before.content ?? "") : "" + const afterText = file.after.exists ? (file.after.content ?? "") : "" + const binary = !!file.before.binary || !!file.after.binary || isBinary(beforeText ?? "") || isBinary(afterText ?? "") + const large = !!file.before.large || !!file.after.large || byteSize(beforeText ?? "") > DISPLAY_LIMIT || byteSize(afterText ?? "") > DISPLAY_LIMIT if (binary || large) { return { path: file.displayPath, status: currentStatus, ...(binary ? { binary: true } : {}), ...(large ? { large: true } : {}), + restoreAvailable: canRestore(file.before) && canRestore(file.after), expandable: false, } } @@ -167,20 +183,24 @@ function displayRow(sessionID: SessionID, messageID: MessageID) { async function currentState(file: string): Promise { try { - return { exists: true, content: await fs.readFile(file, "utf-8") } + const stat = await fs.stat(file) + if (stat.isDirectory()) return { exists: true, restorable: false, hash: "directory", binary: true } + const content = await fs.readFile(file, "utf-8") + return { exists: true, content, hash: "sha256:" + crypto.createHash("sha256").update(content).digest("hex") } } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return { exists: false } - throw err + return { exists: true, restorable: false, hash: `error:${(err as NodeJS.ErrnoException).code ?? "unknown"}` } } } async function applyState(file: string, state: FileState) { + if (!canRestore(state)) throw new Error("restore data unavailable") if (!state.exists) { await fs.rm(file, { force: true }) return } await fs.mkdir(path.dirname(file), { recursive: true }) - await fs.writeFile(file, state.content, "utf-8") + await fs.writeFile(file, (state.bom ? "\uFEFF" : "") + (state.content ?? ""), "utf-8") } function withAvailability(display: Display, state: "applied" | "undone" | "redo_invalidated") { @@ -213,6 +233,7 @@ export namespace TurnChange { sensitive: z.boolean().optional(), binary: z.boolean().optional(), large: z.boolean().optional(), + restoreAvailable: z.boolean().optional(), expandable: z.boolean(), }) @@ -242,6 +263,29 @@ export namespace TurnChange { }), ]) + function prepareState(state: FileState): FileState { + if (!state.exists) return state + const content = state.content ?? "" + const binary = isBinary(content) + const large = byteSize(content) > RESTORE_LIMIT + const hashValue = "sha256:" + crypto.createHash("sha256").update(content).digest("hex") + if (binary || large) { + return { exists: true, hash: hashValue, restorable: false, binary, large, bom: state.bom } + } + return { ...state, hash: hashValue, restorable: true } + } + + function nextPosition(sessionID: SessionID, messageID: MessageID) { + const row = Database.use((db) => + db + .select({ value: count() }) + .from(TurnChangeRestoreTable) + .where(and(eq(TurnChangeRestoreTable.session_id, sessionID), eq(TurnChangeRestoreTable.message_id, messageID))) + .get(), + ) + return row?.value ?? 0 + } + export function recordWrite(input: { sessionID: SessionID messageID: MessageID @@ -250,8 +294,6 @@ export namespace TurnChange { after: FileState }) { if (!input.messageID) return - if (input.before.exists && input.before.content.length > RESTORE_LIMIT) return - if (input.after.exists && input.after.content.length > RESTORE_LIMIT) return const existing = Database.use((db) => db .select() @@ -270,8 +312,8 @@ export namespace TurnChange { const data: RestoreFile = { path: input.path, displayPath: displayPath(input.path), - before: existing?.data.before ?? input.before, - after: input.after, + before: existing?.data.before ?? prepareState(input.before), + after: prepareState(input.after), } if (existing) { Database.use((db) => @@ -290,8 +332,8 @@ export namespace TurnChange { return } - const count = rows(input.sessionID, input.messageID).length - if (count >= MAX_FILES) return + const position = nextPosition(input.sessionID, input.messageID) + if (position >= MAX_FILES) return Database.use((db) => db .insert(TurnChangeRestoreTable) @@ -299,7 +341,7 @@ export namespace TurnChange { session_id: input.sessionID, message_id: input.messageID, file_path: input.path, - position: count, + position, data, finalized: false, time_created: time, @@ -381,10 +423,20 @@ export namespace TurnChange { const blocked: Array<{ path: string; reason: string }> = [] for (const row of restore) { const expected = input.mode === "undo" ? row.data.after : row.data.before + if (!canRestore(input.mode === "undo" ? row.data.before : row.data.after)) { + blocked.push({ path: row.data.displayPath, reason: "restore_unavailable" }) + continue + } const current = await currentState(row.data.path) - if (!same(current, expected)) blocked.push({ path: row.data.displayPath, reason: "changed" }) + if (!canRestore(current)) blocked.push({ path: row.data.displayPath, reason: "unavailable" }) + else if (!same(current, expected)) blocked.push({ path: row.data.displayPath, reason: "changed" }) } - if (blocked.length) return { status: "blocked", reason: "conflict", files: blocked } + if (blocked.length) + return { + status: "blocked", + reason: blocked.some((item) => item.reason === "restore_unavailable") ? "unsupported_size" : "conflict", + files: blocked, + } const rollback: Array<{ file: string; state: FileState }> = [] try { @@ -414,10 +466,27 @@ export namespace TurnChange { } export function undo(input: { sessionID: SessionID; messageID: MessageID }) { - return mutate({ ...input, mode: "undo" }) + return locked(input.sessionID, () => mutate({ ...input, mode: "undo" })) } export function redo(input: { sessionID: SessionID; messageID: MessageID }) { - return mutate({ ...input, mode: "redo" }) + return locked(input.sessionID, () => mutate({ ...input, mode: "redo" })) + } +} + +async function locked(sessionID: SessionID, fn: () => Promise) { + const previous = locks.get(sessionID) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const queued = previous.then(() => current) + locks.set(sessionID, queued) + await previous + try { + return await fn() + } finally { + release() + if (locks.get(sessionID) === queued) locks.delete(sessionID) } } diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 513dfc53c..8f862d76c 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -107,6 +107,18 @@ export namespace ShareNext { } } + function sanitizeMessageInfo(info: T): T { + const summary = "summary" in info && typeof info.summary === "object" && info.summary ? info.summary : undefined + if (!summary?.diffs) return info + return { + ...info, + summary: { + ...summary, + diffs: sanitizeSensitiveDiffs(summary.diffs), + }, + } + } + export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -187,7 +199,7 @@ export namespace ShareNext { yield* watch(MessageV2.Event.Updated, (evt) => Effect.gen(function* () { const info = evt.properties.info - yield* sync(info.sessionID, [{ type: "message", data: info }]) + yield* sync(info.sessionID, [{ type: "message", data: sanitizeMessageInfo(info) }]) if (info.role !== "user") return const model = yield* provider.getModel(info.model.providerID, info.model.modelID) yield* sync(info.sessionID, [{ type: "model", data: [model] }]) @@ -278,7 +290,7 @@ export namespace ShareNext { yield* sync(sessionID, [ { type: "session", data: info }, - ...messages.map((item) => ({ type: "message" as const, data: item.info })), + ...messages.map((item) => ({ type: "message" as const, data: sanitizeMessageInfo(item.info) })), ...messages.flatMap((item) => item.parts.map((part) => ({ type: "part" as const, data: sanitizeSensitiveToolPart(part) as SDK.Part })), ), diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 52f7d0bf8..cc73d4db5 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -78,6 +78,12 @@ export const ApplyPatchTool = Tool.define( additions: number deletions: number bom: boolean + beforeExists: boolean + beforeContent?: string + beforeBom: boolean + moveBeforeContent?: string + moveBeforeBom?: boolean + moveBeforeExists?: boolean sensitive: boolean }> = [] @@ -90,6 +96,7 @@ export const ApplyPatchTool = Tool.define( switch (hunk.type) { case "add": { const oldContent = "" + const existing = yield* Bom.readFile(afs, filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) const newContent = hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` const next = Bom.split(newContent) @@ -111,6 +118,10 @@ export const ApplyPatchTool = Tool.define( additions, deletions, bom: next.bom, + beforeExists: !!existing, + beforeContent: existing?.text, + beforeBom: existing?.bom ?? false, + moveBeforeExists: undefined, sensitive: isSensitivePath(filePath), }) @@ -152,6 +163,9 @@ export const ApplyPatchTool = Tool.define( const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined yield* assertExternalDirectoryEffect(ctx, movePath) + const moveBefore = movePath + ? yield* Bom.readFile(afs, movePath).pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined fileChanges.push({ filePath, @@ -163,6 +177,11 @@ export const ApplyPatchTool = Tool.define( additions, deletions, bom, + beforeExists: true, + beforeBom: source.bom, + moveBeforeContent: moveBefore?.text, + moveBeforeBom: moveBefore?.bom, + moveBeforeExists: !!moveBefore, sensitive: isSensitivePath(filePath) || (movePath ? isSensitivePath(movePath) : false), }) @@ -194,6 +213,8 @@ export const ApplyPatchTool = Tool.define( additions: 0, deletions, bom: source.bom, + beforeExists: true, + beforeBom: source.bom, sensitive: isSensitivePath(filePath), }) @@ -316,16 +337,20 @@ export const ApplyPatchTool = Tool.define( sessionID: ctx.sessionID, messageID: ctx.messageID, path: change.movePath, - before: { exists: false }, - after: { exists: true, content: change.newContent }, + before: change.moveBeforeExists + ? { exists: true, content: change.moveBeforeContent ?? "", bom: change.moveBeforeBom ?? false } + : { exists: false }, + after: { exists: true, content: change.newContent, bom: change.bom }, }) } else { TurnChange.recordWrite({ sessionID: ctx.sessionID, messageID: ctx.messageID, path: change.filePath, - before: change.type === "add" ? { exists: false } : { exists: true, content: change.oldContent }, - after: change.type === "delete" ? { exists: false } : { exists: true, content: change.newContent }, + before: change.beforeExists + ? { exists: true, content: change.beforeContent ?? change.oldContent, bom: change.beforeBom } + : { exists: false }, + after: change.type === "delete" ? { exists: false } : { exists: true, content: change.newContent, bom: change.bom }, }) } } @@ -355,7 +380,7 @@ export const ApplyPatchTool = Tool.define( const target = change.movePath ?? change.filePath yield* lsp.touchFile(target, true) } - const diagnostics = yield* lsp.diagnostics() + const diagnostics = fileChanges.some((change) => change.sensitive) ? {} : yield* lsp.diagnostics() // Generate output summary const summaryLines = fileChanges.map((change) => { @@ -373,6 +398,7 @@ export const ApplyPatchTool = Tool.define( for (const change of fileChanges) { if (change.type === "delete") continue const target = change.movePath ?? change.filePath + if (change.sensitive) continue const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? []) if (!block) continue const rel = path.relative(Instance.worktree, target).replaceAll("\\", "/") diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a8f3bde60..24a678c96 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -88,6 +88,8 @@ export const EditTool = Tool.define( let contentNew = "" let existedBefore = true let bomDiscarded = false + let beforeBom = false + let afterBom = false yield* lock(filePath).withPermits(1)( Effect.gen(function* () { if (params.oldString === "") { @@ -113,6 +115,8 @@ export const EditTool = Tool.define( // show, silently breaking shebangs and first-token parsing on // BOM-less scripts. const desiredBom = source.bom + beforeBom = source.bom + afterBom = desiredBom const bomChanged = source.bom !== next.bom bomDiscarded = bomChanged contentOld = source.text @@ -169,6 +173,8 @@ export const EditTool = Tool.define( // text introduce a new BOM, since the diff preview cannot // surface that byte-level change. const desiredBom = source.bom + beforeBom = source.bom + afterBom = desiredBom const bomChanged = source.bom !== next.bom bomDiscarded = bomChanged contentNew = next.text @@ -244,13 +250,13 @@ export const EditTool = Tool.define( sessionID: ctx.sessionID, messageID: ctx.messageID, path: filePath, - before: existedBefore ? { exists: true, content: contentOld } : { exists: false }, - after: { exists: true, content: contentNew }, + before: existedBefore ? { exists: true, content: contentOld, bom: beforeBom } : { exists: false }, + after: { exists: true, content: contentNew, bom: afterBom }, }) let output = "Edit applied successfully." yield* lsp.touchFile(filePath, true) - const diagnostics = yield* lsp.diagnostics() + const diagnostics = sensitive ? {} : yield* lsp.diagnostics() const normalizedFilePath = AppFileSystem.normalizePath(filePath) const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? []) if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}` diff --git a/packages/opencode/src/tool/sensitive.ts b/packages/opencode/src/tool/sensitive.ts index 21ff4564d..e8e6c7879 100644 --- a/packages/opencode/src/tool/sensitive.ts +++ b/packages/opencode/src/tool/sensitive.ts @@ -81,8 +81,11 @@ export function sanitizeSensitiveDiffs isSensitivePath(item.file) ? { - file: item.file, + ...item, status: item.status ?? "modified", + ...("patch" in item ? { patch: "" } : {}), + ...("additions" in item ? { additions: 0 } : {}), + ...("deletions" in item ? { deletions: 0 } : {}), sensitive: true, } : item, @@ -152,13 +155,17 @@ export function sanitizeSensitiveToolPart { expect(serialized).not.toContain("@@") expect((redacted as MessageV2.ToolPart).state).toMatchObject({ input: { filePath: "/tmp/project/.env", sensitive: true }, + output: "Sensitive file updated.", metadata: { filediff: { file: "/tmp/project/.env", diff --git a/packages/opencode/test/session/session-artifacts.test.ts b/packages/opencode/test/session/session-artifacts.test.ts index 5a4b8b9d6..025312e78 100644 --- a/packages/opencode/test/session/session-artifacts.test.ts +++ b/packages/opencode/test/session/session-artifacts.test.ts @@ -104,7 +104,9 @@ describe("session artifacts", () => { expect(serialized).not.toContain("old-secret") expect(serialized).not.toContain("new-secret") - expect(diff as unknown).toEqual([{ file: ".env", status: "modified", sensitive: true }]) + expect(diff as unknown).toEqual([ + { file: ".env", patch: "", additions: 0, deletions: 0, status: "modified", sensitive: true }, + ]) const stored = await AppRuntime.runPromise( Effect.gen(function* () { diff --git a/packages/opencode/test/session/turn-change.test.ts b/packages/opencode/test/session/turn-change.test.ts index fc33e835c..bcfd45ad1 100644 --- a/packages/opencode/test/session/turn-change.test.ts +++ b/packages/opencode/test/session/turn-change.test.ts @@ -106,6 +106,71 @@ describe("TurnChange", () => { }) }) + test("finalizes large files as status-only and blocks restore when target content is unavailable", async () => { + await resetDatabase() + await using fixture = await tmpdir() + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const turn = await createTurn() + const target = path.join(fixture.path, "large.txt") + const large = "x".repeat(2 * 1024 * 1024 + 1) + await fs.rm(target, { force: true }) + + TurnChange.recordWrite({ + ...turn, + path: target, + before: { exists: true, content: large }, + after: { exists: false }, + }) + + const display = TurnChange.finalize(turn) + expect(display?.files).toEqual([ + { + path: "large.txt", + status: "deleted", + large: true, + restoreAvailable: false, + expandable: false, + }, + ]) + + const result = await TurnChange.undo(turn) + expect(result).toMatchObject({ + status: "blocked", + reason: "unsupported_size", + files: [{ path: "large.txt", reason: "restore_unavailable" }], + }) + await expect(fs.readFile(target, "utf-8")).rejects.toThrow() + }, + }) + }) + + test("undo restores BOM bytes", async () => { + await resetDatabase() + await using fixture = await tmpdir() + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const turn = await createTurn() + const target = path.join(fixture.path, "bom.txt") + await fs.writeFile(target, "after\n", "utf-8") + TurnChange.recordWrite({ + ...turn, + path: target, + before: { exists: true, content: "before\n", bom: true }, + after: { exists: true, content: "after\n", bom: false }, + }) + TurnChange.finalize(turn) + + expect((await TurnChange.undo(turn)).status).toBe("applied") + const bytes = await fs.readFile(target) + expect([...bytes.slice(0, 3)]).toEqual([0xef, 0xbb, 0xbf]) + expect(bytes.toString("utf-8")).toBe("\uFEFFbefore\n") + }, + }) + }) + test("undo preflight conflict writes no files", async () => { await resetDatabase() await using fixture = await tmpdir() diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 73803e453..fde336e3a 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -11,7 +11,9 @@ import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { Provider } from "../../src/provider/provider" import { Session } from "../../src/session" -import type { SessionID } from "../../src/session/schema" +import { MessageID, type SessionID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { MessageV2 } from "../../src/session/message-v2" import { ShareNext } from "../../src/share/share-next" import { ShareRuntime } from "../../src/share/runtime" import { Storage } from "../../src/storage/storage" @@ -422,7 +424,7 @@ describe("ShareNext", () => { expect(body.data).toEqual([ { type: "session_diff", - data: [{ file: ".env", status: "modified", sensitive: true }], + data: [{ file: ".env", patch: "", additions: 0, deletions: 0, status: "modified", sensitive: true }], }, ]) }).pipe(Effect.provide(wired(client))) @@ -430,4 +432,82 @@ describe("ShareNext", () => { { config: { enterprise: { url: "https://legacy-share.example.com" } } }, ), ) + + it.live("ShareNext redacts sensitive diffs embedded in message sync payloads", () => + provideTmpdirInstance( + () => { + const seen: Array<{ url: string; body: string }> = [] + const client = HttpClient.make((req) => { + if (req.url.endsWith("/sync") && req.body._tag === "Uint8Array") { + seen.push({ url: req.url, body: new TextDecoder().decode(req.body.body) }) + } + return Effect.succeed(json(req, { ok: true })) + }) + + return Effect.gen(function* () { + const bus = yield* Bus.Service + const share = yield* ShareNext.Service + const session = yield* Session.Service + + const info = yield* session.create({ title: "first" }) + yield* share.init() + yield* Effect.sleep(50) + yield* Effect.sync(() => + Database.use((db) => + db + .insert(SessionShareTable) + .values({ + session_id: info.id, + id: "shr_abc", + url: "https://legacy-share.example.com/share/abc", + secret: "sec_123", + }) + .run(), + ), + ) + + yield* bus.publish(MessageV2.Event.Updated, { + sessionID: info.id, + info: { + id: MessageID.make("msg_sensitive_summary"), + sessionID: info.id, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + tools: {}, + summary: { + diffs: [ + { + file: ".env", + patch: "@@\n-TOKEN=old-secret\n+TOKEN=new-secret\n", + additions: 1, + deletions: 1, + status: "modified", + }, + ], + }, + }, + }) + yield* Effect.sleep(1_250) + + expect(seen).toHaveLength(1) + const body = JSON.parse(seen[0].body) + const serialized = JSON.stringify(body) + expect(serialized).not.toContain("old-secret") + expect(serialized).not.toContain("new-secret") + expect(serialized).not.toContain("@@") + expect(body.data[0]).toMatchObject({ + type: "message", + data: { + summary: { + diffs: [{ file: ".env", patch: "", additions: 0, deletions: 0, status: "modified", sensitive: true }], + }, + }, + }) + }).pipe(Effect.provide(wired(client))) + }, + { config: { enterprise: { url: "https://legacy-share.example.com" } } }, + ), + ) }) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index dc0545d9e..9d8885a17 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -154,6 +154,7 @@ type TurnChangeFile = { sensitive?: boolean binary?: boolean large?: boolean + restoreAvailable?: boolean expandable: boolean } @@ -309,7 +310,13 @@ export function SessionTurn( { equals: same }, ) - const turnChangeMessageID = createMemo(() => assistantMessages().findLast((item) => item.time.completed)?.id) + const turnChangeMessageID = createMemo(() => { + const messages = assistantMessages() + return ( + messages.findLast((item) => item.time.completed && (props.turnChanges?.[item.id]?.files.length ?? 0) > 0)?.id ?? + messages.findLast((item) => item.time.completed)?.id + ) + }) const turnChange = createMemo(() => { const id = turnChangeMessageID() return id ? props.turnChanges?.[id] : undefined @@ -352,6 +359,11 @@ export function SessionTurn( const base = action === "undo" ? "Undo" : "Redo" return confirmAction() === action ? `${base}?` : base }) + const turnStatusLabel = (status: TurnChangeFile["status"]) => { + if (status === "added") return "Added" + if (status === "deleted") return "Deleted" + return "Updated" + } const parentPath = (value: string) => { const normalized = value.replaceAll("\\", "/") const index = normalized.lastIndexOf("/") @@ -566,7 +578,7 @@ export function SessionTurn( Updated} + fallback={{turnStatusLabel(file.status)}} > +{file.additions ?? 0} -{file.deletions ?? 0} @@ -610,7 +622,7 @@ export function SessionTurn(
- 0 && !working()}> + 0 && !working()}>
Date: Sun, 3 May 2026 21:15:21 +0800 Subject: [PATCH 03/17] fix: address turn change review followups --- packages/opencode/src/server/instance/session.ts | 2 +- packages/opencode/src/session/turn-change.ts | 10 ++++++++-- packages/opencode/src/tool/apply_patch.ts | 2 +- packages/opencode/test/session/turn-change.test.ts | 5 +++++ packages/opencode/test/share/share-next.test.ts | 7 +++++-- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 0a8f08834..0110f612c 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -42,7 +42,6 @@ function publishTurnChangeFiles(display: TurnChangeDisplay, mode: "undo" | "redo const lsp = yield* LSP.Service for (const file of display.files) { if (!file.openPath) continue - yield* bus.publish(File.Event.Edited, { file: file.openPath }) const event = mode === "redo" ? file.status === "added" @@ -55,6 +54,7 @@ function publishTurnChangeFiles(display: TurnChangeDisplay, mode: "undo" | "redo : file.status === "deleted" ? "add" : "change" + if (event !== "unlink") yield* bus.publish(File.Event.Edited, { file: file.openPath }) yield* bus.publish(FileWatcher.Event.Updated, { file: file.openPath, event }) if (event !== "unlink") yield* lsp.touchFile(file.openPath, true) } diff --git a/packages/opencode/src/session/turn-change.ts b/packages/opencode/src/session/turn-change.ts index 8fce1fade..071f6a7bf 100644 --- a/packages/opencode/src/session/turn-change.ts +++ b/packages/opencode/src/session/turn-change.ts @@ -11,6 +11,7 @@ import { Instance } from "@/project/instance" import { isSensitivePath } from "@/tool/sensitive" import { trimDiff } from "@/tool/edit" import { Global } from "@/global" +import * as Bom from "@/util/bom" type FileState = | { exists: false; content?: undefined; hash?: string; restorable?: boolean; bom?: boolean; large?: boolean; binary?: boolean } @@ -185,8 +186,13 @@ async function currentState(file: string): Promise { try { const stat = await fs.stat(file) if (stat.isDirectory()) return { exists: true, restorable: false, hash: "directory", binary: true } - const content = await fs.readFile(file, "utf-8") - return { exists: true, content, hash: "sha256:" + crypto.createHash("sha256").update(content).digest("hex") } + const current = Bom.split(await fs.readFile(file, "utf-8")) + return { + exists: true, + content: current.text, + bom: current.bom, + hash: "sha256:" + crypto.createHash("sha256").update(current.text).digest("hex"), + } } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return { exists: false } return { exists: true, restorable: false, hash: `error:${(err as NodeJS.ErrnoException).code ?? "unknown"}` } diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index cc73d4db5..a13cc98ad 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -330,7 +330,7 @@ export const ApplyPatchTool = Tool.define( sessionID: ctx.sessionID, messageID: ctx.messageID, path: change.filePath, - before: { exists: true, content: change.oldContent }, + before: { exists: true, content: change.oldContent, bom: change.beforeBom }, after: { exists: false }, }) TurnChange.recordWrite({ diff --git a/packages/opencode/test/session/turn-change.test.ts b/packages/opencode/test/session/turn-change.test.ts index bcfd45ad1..785c21680 100644 --- a/packages/opencode/test/session/turn-change.test.ts +++ b/packages/opencode/test/session/turn-change.test.ts @@ -167,6 +167,11 @@ describe("TurnChange", () => { const bytes = await fs.readFile(target) expect([...bytes.slice(0, 3)]).toEqual([0xef, 0xbb, 0xbf]) expect(bytes.toString("utf-8")).toBe("\uFEFFbefore\n") + + expect((await TurnChange.redo(turn)).status).toBe("applied") + const redoBytes = await fs.readFile(target) + expect([...redoBytes.slice(0, 3)]).not.toEqual([0xef, 0xbb, 0xbf]) + expect(redoBytes.toString("utf-8")).toBe("after\n") }, }) }) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index fde336e3a..9657ec7c8 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -492,12 +492,15 @@ describe("ShareNext", () => { yield* Effect.sleep(1_250) expect(seen).toHaveLength(1) - const body = JSON.parse(seen[0].body) + const parsed = seen.map((item) => JSON.parse(item.body)) + const body = parsed.find((item) => item.data.some((entry: { type: string }) => entry.type === "message")) + expect(body).toBeDefined() const serialized = JSON.stringify(body) expect(serialized).not.toContain("old-secret") expect(serialized).not.toContain("new-secret") expect(serialized).not.toContain("@@") - expect(body.data[0]).toMatchObject({ + const message = body.data.find((entry: { type: string }) => entry.type === "message") + expect(message).toMatchObject({ type: "message", data: { summary: { From 2411a4a118871808b28a0a21eba115a5a2cae1f7 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 21:25:28 +0800 Subject: [PATCH 04/17] fix: harden turn change consistency --- packages/opencode/src/session/turn-change.ts | 146 ++++++++++++------- 1 file changed, 91 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/session/turn-change.ts b/packages/opencode/src/session/turn-change.ts index 071f6a7bf..1264159a9 100644 --- a/packages/opencode/src/session/turn-change.ts +++ b/packages/opencode/src/session/turn-change.ts @@ -70,6 +70,15 @@ const RESTORE_LIMIT = 2 * 1024 * 1024 const MAX_FILES = 200 const locks = new Map>() +class RestoreConflictError extends Error { + constructor( + readonly file: string, + readonly displayPath: string, + ) { + super("restore conflict") + } +} + function now() { return Date.now() } @@ -77,7 +86,7 @@ function now() { function hash(state: FileState) { if (!state.exists) return "missing" if (state.hash) return state.hash - return "sha256:" + crypto.createHash("sha256").update(state.content ?? "").digest("hex") + return stateHash(state.content ?? "", state.bom) } function same(a: FileState, b: FileState) { @@ -105,6 +114,10 @@ function byteSize(text: string) { return Buffer.byteLength(text, "utf8") } +function stateHash(content: string, bom?: boolean) { + return "sha256:" + crypto.createHash("sha256").update(`${bom ? "bom:1" : "bom:0"}\0${content}`).digest("hex") +} + function additionsDeletions(before: string, after: string) { let additions = 0 let deletions = 0 @@ -191,7 +204,7 @@ async function currentState(file: string): Promise { exists: true, content: current.text, bom: current.bom, - hash: "sha256:" + crypto.createHash("sha256").update(current.text).digest("hex"), + hash: stateHash(current.text, current.bom), } } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return { exists: false } @@ -274,7 +287,7 @@ export namespace TurnChange { const content = state.content ?? "" const binary = isBinary(content) const large = byteSize(content) > RESTORE_LIMIT - const hashValue = "sha256:" + crypto.createHash("sha256").update(content).digest("hex") + const hashValue = stateHash(content, state.bom) if (binary || large) { return { exists: true, hash: hashValue, restorable: false, binary, large, bom: state.bom } } @@ -292,6 +305,18 @@ export namespace TurnChange { return row?.value ?? 0 } + function restoreWhere(input: { sessionID: SessionID; messageID: MessageID; path: string }) { + return and( + eq(TurnChangeRestoreTable.session_id, input.sessionID), + eq(TurnChangeRestoreTable.message_id, input.messageID), + eq(TurnChangeRestoreTable.file_path, input.path), + ) + } + + function getRestore(input: { sessionID: SessionID; messageID: MessageID; path: string }) { + return Database.use((db) => db.select().from(TurnChangeRestoreTable).where(restoreWhere(input)).get()) as RestoreRow | undefined + } + export function recordWrite(input: { sessionID: SessionID messageID: MessageID @@ -300,61 +325,67 @@ export namespace TurnChange { after: FileState }) { if (!input.messageID) return - const existing = Database.use((db) => - db - .select() - .from(TurnChangeRestoreTable) - .where( - and( - eq(TurnChangeRestoreTable.session_id, input.sessionID), - eq(TurnChangeRestoreTable.message_id, input.messageID), - eq(TurnChangeRestoreTable.file_path, input.path), - ), + try { + const existing = getRestore(input) + + const time = now() + const data: RestoreFile = { + path: input.path, + displayPath: displayPath(input.path), + before: existing?.data.before ?? prepareState(input.before), + after: prepareState(input.after), + } + if (existing) { + Database.use((db) => + db + .update(TurnChangeRestoreTable) + .set({ data, time_updated: time }) + .where(restoreWhere(input)) + .run(), ) - .get(), - ) as RestoreRow | undefined + return + } - const time = now() - const data: RestoreFile = { - path: input.path, - displayPath: displayPath(input.path), - before: existing?.data.before ?? prepareState(input.before), - after: prepareState(input.after), - } - if (existing) { - Database.use((db) => - db - .update(TurnChangeRestoreTable) - .set({ data, time_updated: time }) - .where( - and( - eq(TurnChangeRestoreTable.session_id, input.sessionID), - eq(TurnChangeRestoreTable.message_id, input.messageID), - eq(TurnChangeRestoreTable.file_path, input.path), - ), + const position = nextPosition(input.sessionID, input.messageID) + if (position >= MAX_FILES) return + try { + Database.use((db) => + db + .insert(TurnChangeRestoreTable) + .values({ + session_id: input.sessionID, + message_id: input.messageID, + file_path: input.path, + position, + data, + finalized: false, + time_created: time, + time_updated: time, + }) + .run(), + ) + } catch { + try { + const current = getRestore(input) + if (!current) return + const retryData: RestoreFile = { + ...data, + before: current.data.before, + } + Database.use((db) => + db + .update(TurnChangeRestoreTable) + .set({ data: retryData, time_updated: time }) + .where(restoreWhere(input)) + .run(), ) - .run(), - ) + } catch { + return + } + } + } catch { return } - - const position = nextPosition(input.sessionID, input.messageID) - if (position >= MAX_FILES) return - Database.use((db) => - db - .insert(TurnChangeRestoreTable) - .values({ - session_id: input.sessionID, - message_id: input.messageID, - file_path: input.path, - position, - data, - finalized: false, - time_created: time, - time_updated: time, - }) - .run(), - ) } export function finalize(input: { sessionID: SessionID; messageID: MessageID }) { @@ -447,13 +478,18 @@ export namespace TurnChange { const rollback: Array<{ file: string; state: FileState }> = [] try { for (const row of restore) { - rollback.push({ file: row.data.path, state: await currentState(row.data.path) }) + const expected = input.mode === "undo" ? row.data.after : row.data.before + const current = await currentState(row.data.path) + if (!same(current, expected)) throw new RestoreConflictError(row.data.path, row.data.displayPath) + rollback.push({ file: row.data.path, state: current }) await applyState(row.data.path, input.mode === "undo" ? row.data.before : row.data.after) } - } catch { + } catch (err) { for (const item of rollback.reverse()) { await applyState(item.file, item.state).catch(() => undefined) } + if (err instanceof RestoreConflictError) + return { status: "blocked", reason: "conflict", files: [{ path: err.displayPath, reason: "changed" }] } return { status: "blocked", reason: "write_failed", files: rollback.map((item) => ({ path: displayPath(item.file), reason: "rollback" })) } } From ad387e3b8530a068ad3b0bed86d9c443e01228f5 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 21:53:04 +0800 Subject: [PATCH 05/17] fix: isolate turn change finalization --- packages/opencode/src/session/turn-change.ts | 113 ++++++++++-------- .../opencode/test/session/turn-change.test.ts | 65 +++++++++- 2 files changed, 126 insertions(+), 52 deletions(-) diff --git a/packages/opencode/src/session/turn-change.ts b/packages/opencode/src/session/turn-change.ts index 1264159a9..5899c7af2 100644 --- a/packages/opencode/src/session/turn-change.ts +++ b/packages/opencode/src/session/turn-change.ts @@ -12,6 +12,7 @@ import { isSensitivePath } from "@/tool/sensitive" import { trimDiff } from "@/tool/edit" import { Global } from "@/global" import * as Bom from "@/util/bom" +import { Log } from "@opencode-ai/core/util/log" type FileState = | { exists: false; content?: undefined; hash?: string; restorable?: boolean; bom?: boolean; large?: boolean; binary?: boolean } @@ -69,6 +70,7 @@ const DISPLAY_LIMIT = 2 * 1024 * 1024 const RESTORE_LIMIT = 2 * 1024 * 1024 const MAX_FILES = 200 const locks = new Map>() +const log = Log.create({ service: "session.turn-change" }) class RestoreConflictError extends Error { constructor( @@ -199,6 +201,7 @@ async function currentState(file: string): Promise { try { const stat = await fs.stat(file) if (stat.isDirectory()) return { exists: true, restorable: false, hash: "directory", binary: true } + if (stat.size > RESTORE_LIMIT) return { exists: true, restorable: false, hash: `large:${stat.size}`, large: true } const current = Bom.split(await fs.readFile(file, "utf-8")) return { exists: true, @@ -389,58 +392,66 @@ export namespace TurnChange { } export function finalize(input: { sessionID: SessionID; messageID: MessageID }) { - const files = rows(input.sessionID, input.messageID) - if (!files.length) return - const displayFiles = files.map((row) => toDisplay(row.data)).filter(Boolean) as DisplayFile[] - if (!displayFiles.length) return - const time = now() - const display: Display = { - sessionID: input.sessionID, - turnID: input.messageID, - messageID: input.messageID, - undoAvailable: true, - redoAvailable: false, - files: displayFiles, + try { + const files = rows(input.sessionID, input.messageID) + if (!files.length) return + const displayFiles = files.map((row) => toDisplay(row.data)).filter(Boolean) as DisplayFile[] + if (!displayFiles.length) return + const time = now() + const display: Display = { + sessionID: input.sessionID, + turnID: input.messageID, + messageID: input.messageID, + undoAvailable: true, + redoAvailable: false, + files: displayFiles, + } + Database.transaction((db) => { + db + .update(TurnChangeDisplayTable) + .set({ state: "redo_invalidated", time_updated: time }) + .where( + and( + eq(TurnChangeDisplayTable.session_id, input.sessionID), + ne(TurnChangeDisplayTable.message_id, input.messageID), + eq(TurnChangeDisplayTable.state, "undone"), + ), + ) + .run() + db + .insert(TurnChangeDisplayTable) + .values({ + session_id: input.sessionID, + message_id: input.messageID, + data: display, + state: "applied", + time_created: time, + time_updated: time, + }) + .onConflictDoUpdate({ + target: [TurnChangeDisplayTable.session_id, TurnChangeDisplayTable.message_id], + set: { data: display, state: "applied", time_updated: time }, + }) + .run() + db + .update(TurnChangeRestoreTable) + .set({ finalized: true, time_updated: time }) + .where( + and( + eq(TurnChangeRestoreTable.session_id, input.sessionID), + eq(TurnChangeRestoreTable.message_id, input.messageID), + ), + ) + .run() + }) + return display + } catch (err) { + log.warn("failed to finalize turn changes", { + sessionID: input.sessionID, + messageID: input.messageID, + error: err instanceof Error ? err.name : typeof err, + }) } - Database.transaction((db) => { - db - .update(TurnChangeDisplayTable) - .set({ state: "redo_invalidated", time_updated: time }) - .where( - and( - eq(TurnChangeDisplayTable.session_id, input.sessionID), - ne(TurnChangeDisplayTable.message_id, input.messageID), - eq(TurnChangeDisplayTable.state, "undone"), - ), - ) - .run() - db - .insert(TurnChangeDisplayTable) - .values({ - session_id: input.sessionID, - message_id: input.messageID, - data: display, - state: "applied", - time_created: time, - time_updated: time, - }) - .onConflictDoUpdate({ - target: [TurnChangeDisplayTable.session_id, TurnChangeDisplayTable.message_id], - set: { data: display, state: "applied", time_updated: time }, - }) - .run() - db - .update(TurnChangeRestoreTable) - .set({ finalized: true, time_updated: time }) - .where( - and( - eq(TurnChangeRestoreTable.session_id, input.sessionID), - eq(TurnChangeRestoreTable.message_id, input.messageID), - ), - ) - .run() - }) - return display } export function get(input: { sessionID: SessionID; messageID: MessageID }) { diff --git a/packages/opencode/test/session/turn-change.test.ts b/packages/opencode/test/session/turn-change.test.ts index 785c21680..ac30b948f 100644 --- a/packages/opencode/test/session/turn-change.test.ts +++ b/packages/opencode/test/session/turn-change.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { Instance } from "../../src/project/instance" @@ -7,6 +7,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { TurnChange } from "../../src/session/turn-change" import { MessageID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" +import { Database } from "../../src/storage/db" import { tmpdir } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" @@ -202,6 +203,68 @@ describe("TurnChange", () => { }) }) + test("undo preflight blocks oversized current files without reading content", async () => { + await resetDatabase() + await using fixture = await tmpdir() + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const turn = await createTurn() + const target = path.join(fixture.path, "file.txt") + await fs.writeFile(target, "after\n", "utf-8") + TurnChange.recordWrite({ + ...turn, + path: target, + before: { exists: true, content: "before\n" }, + after: { exists: true, content: "after\n" }, + }) + TurnChange.finalize(turn) + await fs.writeFile(target, "x".repeat(2 * 1024 * 1024 + 1), "utf-8") + + const readFile = spyOn(fs, "readFile") + try { + const result = await TurnChange.undo(turn) + + expect(result).toMatchObject({ + status: "blocked", + reason: "conflict", + files: [{ path: "file.txt", reason: "unavailable" }], + }) + expect(readFile.mock.calls.some((call) => call[0] === target)).toBe(false) + } finally { + readFile.mockRestore() + } + }, + }) + }) + + test("finalize failure is isolated from caller cleanup", async () => { + await resetDatabase() + await using fixture = await tmpdir() + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const turn = await createTurn() + const target = path.join(fixture.path, "file.txt") + TurnChange.recordWrite({ + ...turn, + path: target, + before: { exists: false }, + after: { exists: true, content: "after\n" }, + }) + const transaction = spyOn(Database, "transaction").mockImplementation(() => { + throw new Error("db unavailable") + }) + try { + expect(() => TurnChange.finalize(turn)).not.toThrow() + expect(TurnChange.get(turn)).toBeUndefined() + } finally { + transaction.mockRestore() + } + }, + }) + }) + test("later finalized turn invalidates previous redo", async () => { await resetDatabase() await using fixture = await tmpdir() From 0e2f9efb02a72a82b1e8a78e1898342ac05dc8a0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 21:54:45 +0800 Subject: [PATCH 06/17] fix: classify turn change permission failures --- packages/opencode/src/session/turn-change.ts | 25 ++++++++++++- .../opencode/test/session/turn-change.test.ts | 37 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/turn-change.ts b/packages/opencode/src/session/turn-change.ts index 5899c7af2..5e3618932 100644 --- a/packages/opencode/src/session/turn-change.ts +++ b/packages/opencode/src/session/turn-change.ts @@ -120,6 +120,15 @@ function stateHash(content: string, bom?: boolean) { return "sha256:" + crypto.createHash("sha256").update(`${bom ? "bom:1" : "bom:0"}\0${content}`).digest("hex") } +function isPermissionCode(code: string | undefined) { + return code === "EACCES" || code === "EPERM" +} + +function stateErrorCode(state: FileState) { + if (!state.hash?.startsWith("error:")) return + return state.hash.slice("error:".length) +} + function additionsDeletions(before: string, after: string) { let additions = 0 let deletions = 0 @@ -476,13 +485,18 @@ export namespace TurnChange { continue } const current = await currentState(row.data.path) - if (!canRestore(current)) blocked.push({ path: row.data.displayPath, reason: "unavailable" }) + if (isPermissionCode(stateErrorCode(current))) blocked.push({ path: row.data.displayPath, reason: "permission_denied" }) + else if (!canRestore(current)) blocked.push({ path: row.data.displayPath, reason: "unavailable" }) else if (!same(current, expected)) blocked.push({ path: row.data.displayPath, reason: "changed" }) } if (blocked.length) return { status: "blocked", - reason: blocked.some((item) => item.reason === "restore_unavailable") ? "unsupported_size" : "conflict", + reason: blocked.some((item) => item.reason === "permission_denied") + ? "permission_denied" + : blocked.some((item) => item.reason === "restore_unavailable") + ? "unsupported_size" + : "conflict", files: blocked, } @@ -496,9 +510,16 @@ export namespace TurnChange { await applyState(row.data.path, input.mode === "undo" ? row.data.before : row.data.after) } } catch (err) { + const code = (err as NodeJS.ErrnoException).code for (const item of rollback.reverse()) { await applyState(item.file, item.state).catch(() => undefined) } + if (isPermissionCode(code)) + return { + status: "blocked", + reason: "permission_denied", + files: rollback.map((item) => ({ path: displayPath(item.file), reason: "permission_denied" })), + } if (err instanceof RestoreConflictError) return { status: "blocked", reason: "conflict", files: [{ path: err.displayPath, reason: "changed" }] } return { status: "blocked", reason: "write_failed", files: rollback.map((item) => ({ path: displayPath(item.file), reason: "rollback" })) } diff --git a/packages/opencode/test/session/turn-change.test.ts b/packages/opencode/test/session/turn-change.test.ts index ac30b948f..96ce22ee5 100644 --- a/packages/opencode/test/session/turn-change.test.ts +++ b/packages/opencode/test/session/turn-change.test.ts @@ -265,6 +265,43 @@ describe("TurnChange", () => { }) }) + test("undo reports permission failures separately from generic write failures", async () => { + await resetDatabase() + await using fixture = await tmpdir() + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const turn = await createTurn() + const target = path.join(fixture.path, "file.txt") + await fs.writeFile(target, "after\n", "utf-8") + TurnChange.recordWrite({ + ...turn, + path: target, + before: { exists: true, content: "before\n" }, + after: { exists: true, content: "after\n" }, + }) + TurnChange.finalize(turn) + + const writeFile = spyOn(fs, "writeFile").mockImplementation(() => { + const err = new Error("permission denied") as NodeJS.ErrnoException + err.code = "EACCES" + throw err + }) + try { + const result = await TurnChange.undo(turn) + + expect(result).toMatchObject({ + status: "blocked", + reason: "permission_denied", + files: [{ path: "file.txt", reason: "permission_denied" }], + }) + } finally { + writeFile.mockRestore() + } + }, + }) + }) + test("later finalized turn invalidates previous redo", async () => { await resetDatabase() await using fixture = await tmpdir() From bcff1738352390405ae1201716f59ebb32d7ae7e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 21:58:49 +0800 Subject: [PATCH 07/17] fix: localize turn change UI --- packages/app/src/i18n/en.ts | 11 ++++++++ packages/app/src/i18n/zh.ts | 10 +++++++ packages/app/src/pages/session/files-tab.tsx | 10 ++++--- .../src/pages/session/message-timeline.tsx | 13 +++++++--- packages/ui/src/components/session-turn.tsx | 26 ++++++++++++------- packages/ui/src/i18n/en.ts | 11 ++++++++ packages/ui/src/i18n/zh.ts | 15 +++++++++++ 7 files changed, 80 insertions(+), 16 deletions(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index f002cf49f..dd043974a 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -607,12 +607,23 @@ export const dict = { "session.files.all": "All files", "session.files.empty": "No files", "session.files.binaryContent": "Binary file (content cannot be displayed)", + "session.files.status.added": "Added", + "session.files.status.updated": "Updated", + "session.files.missing": "Missing", + "session.files.notFound": "File not found", "session.messages.renderEarlier": "Render earlier messages", "session.messages.loadingEarlier": "Loading earlier messages...", "session.messages.loadEarlier": "Load earlier messages", "session.messages.loading": "Loading messages...", "session.messages.jumpToLatest": "Jump to latest", + "session.turnChange.undoBlocked": "Undo blocked", + "session.turnChange.redoBlocked": "Redo blocked", + "session.turnChange.blocked.conflict": "The file changed after this turn, so PawWork did not overwrite it.", + "session.turnChange.blocked.unsupportedSize": + "This turn includes a file without restore data, so PawWork did not change anything.", + "session.turnChange.blocked.permissionDenied": "PawWork does not have permission to change this file.", + "session.turnChange.blocked.generic": "PawWork could not apply this change.", "session.context.addToContext": "Add {{selection}} to context", "session.todo.title": "Todos", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 4f1e11f53..d4d210220 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -570,11 +570,21 @@ export const dict = { "session.files.all": "所有文件", "session.files.empty": "无文件", "session.files.binaryContent": "二进制文件(无法显示内容)", + "session.files.status.added": "已添加", + "session.files.status.updated": "已更新", + "session.files.missing": "缺失", + "session.files.notFound": "找不到文件", "session.messages.renderEarlier": "显示更早的消息", "session.messages.loadingEarlier": "正在加载更早的消息...", "session.messages.loadEarlier": "加载更早的消息", "session.messages.loading": "正在加载消息...", "session.messages.jumpToLatest": "跳转到最新", + "session.turnChange.undoBlocked": "无法撤销", + "session.turnChange.redoBlocked": "无法重做", + "session.turnChange.blocked.conflict": "此轮之后文件已被修改,爪印没有覆盖它。", + "session.turnChange.blocked.unsupportedSize": "此轮包含没有恢复数据的文件,爪印没有修改任何内容。", + "session.turnChange.blocked.permissionDenied": "爪印没有权限修改此文件。", + "session.turnChange.blocked.generic": "爪印无法应用此更改。", "session.context.addToContext": "将 {{selection}} 添加到上下文", "session.todo.title": "待办事项", "session.todo.collapse": "折叠", diff --git a/packages/app/src/pages/session/files-tab.tsx b/packages/app/src/pages/session/files-tab.tsx index 2eab49640..41da2bab5 100644 --- a/packages/app/src/pages/session/files-tab.tsx +++ b/packages/app/src/pages/session/files-tab.tsx @@ -132,16 +132,20 @@ export function FilesTab(props: { files: FilesTabEntry[] }) {
{entry.file}
- {entry.kind === "added" ? "Added" : "Updated"} + + {entry.kind === "added" + ? language.t("session.files.status.added") + : language.t("session.files.status.updated")} + • - {meta().exists ? formatSize(meta().size) : "Missing"} + {meta().exists ? formatSize(meta().size) : language.t("session.files.missing")}
-
File not found
+
{language.t("session.files.notFound")}
{preview(entry)}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index da815e638..056851c98 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -348,13 +348,18 @@ export function MessageTimeline(props: { return body.display } showToast({ - title: action === "undo" ? "Undo blocked" : "Redo blocked", + title: + action === "undo" + ? language.t("session.turnChange.undoBlocked") + : language.t("session.turnChange.redoBlocked"), description: body?.reason === "conflict" - ? "The file changed after this turn, so PawWork did not overwrite it." + ? language.t("session.turnChange.blocked.conflict") : body?.reason === "unsupported_size" - ? "This turn includes a file without restore data, so PawWork did not change anything." - : "PawWork could not apply this change.", + ? language.t("session.turnChange.blocked.unsupportedSize") + : body?.reason === "permission_denied" + ? language.t("session.turnChange.blocked.permissionDenied") + : language.t("session.turnChange.blocked.generic"), variant: "error", }) return turnChanges[messageID] ?? undefined diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 9d8885a17..527f6840a 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -356,13 +356,17 @@ export function SessionTurn( const current = turnChange() const action = current?.undoAvailable ? "undo" : current?.redoAvailable ? "redo" : undefined if (!action) return "" - const base = action === "undo" ? "Undo" : "Redo" - return confirmAction() === action ? `${base}?` : base + const base = action === "undo" ? i18n.t("ui.sessionTurn.turnChanges.undo") : i18n.t("ui.sessionTurn.turnChanges.redo") + return confirmAction() === action + ? action === "undo" + ? i18n.t("ui.sessionTurn.turnChanges.undoConfirm") + : i18n.t("ui.sessionTurn.turnChanges.redoConfirm") + : base }) const turnStatusLabel = (status: TurnChangeFile["status"]) => { - if (status === "added") return "Added" - if (status === "deleted") return "Deleted" - return "Updated" + if (status === "added") return i18n.t("ui.sessionTurn.turnChanges.status.added") + if (status === "deleted") return i18n.t("ui.sessionTurn.turnChanges.status.deleted") + return i18n.t("ui.sessionTurn.turnChanges.status.updated") } const parentPath = (value: string) => { const normalized = value.replaceAll("\\", "/") @@ -521,8 +525,12 @@ export function SessionTurn(
- {turnEdited()} {i18n.t("ui.sessionTurn.diffs.changed")}{" "} - {i18n.t(turnEdited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} + {i18n.t( + turnEdited() === 1 + ? "ui.sessionTurn.turnChanges.summary.one" + : "ui.sessionTurn.turnChanges.summary.other", + { count: turnEdited() }, + )} +{turnAdditions()} -{turnDeletions()} @@ -589,7 +597,7 @@ export function SessionTurn( icon="open-file" size="small" variant="ghost" - aria-label="Open file" + aria-label={i18n.t("ui.sessionTurn.turnChanges.openFile")} disabled={file.status === "deleted" || !file.openPath || !props.turnChangeActions?.openFile} onClick={() => file.openPath && props.turnChangeActions?.openFile?.(file.openPath)} /> @@ -597,7 +605,7 @@ export function SessionTurn( icon="folder-add-left" size="small" variant="ghost" - aria-label="Show in folder" + aria-label={i18n.t("ui.sessionTurn.turnChanges.showInFolder")} disabled={!file.openPath || !props.turnChangeActions?.showInFolder} onClick={() => file.openPath && diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 533adb859..e985c10c6 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -44,6 +44,17 @@ export const dict: Record = { "ui.sessionTurn.diffs.showAll": "Show all", "ui.sessionTurn.diffs.showLess": "Show less", "ui.sessionTurn.diffs.more": "+{{count}} more files", + "ui.sessionTurn.turnChanges.summary.one": "{{count}} file changed", + "ui.sessionTurn.turnChanges.summary.other": "{{count}} files changed", + "ui.sessionTurn.turnChanges.undo": "Undo", + "ui.sessionTurn.turnChanges.undoConfirm": "Undo?", + "ui.sessionTurn.turnChanges.redo": "Redo", + "ui.sessionTurn.turnChanges.redoConfirm": "Redo?", + "ui.sessionTurn.turnChanges.status.added": "Added", + "ui.sessionTurn.turnChanges.status.updated": "Updated", + "ui.sessionTurn.turnChanges.status.deleted": "Deleted", + "ui.sessionTurn.turnChanges.openFile": "Open file", + "ui.sessionTurn.turnChanges.showInFolder": "Show in folder", "ui.sessionTurn.retry.retrying": "retrying", "ui.sessionTurn.retry.inSeconds": "in {{seconds}}s", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 290429007..6e0ca389f 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -43,6 +43,21 @@ export const dict = { "ui.sessionTurn.steps.hide": "隐藏步骤", "ui.sessionTurn.summary.response": "回复", "ui.sessionTurn.diff.showMore": "显示更多更改({{count}})", + "ui.sessionTurn.diffs.changed": "已更改", + "ui.sessionTurn.diffs.showAll": "全部显示", + "ui.sessionTurn.diffs.showLess": "收起", + "ui.sessionTurn.diffs.more": "还有 {{count}} 个文件", + "ui.sessionTurn.turnChanges.summary.one": "{{count}} 个文件已更改", + "ui.sessionTurn.turnChanges.summary.other": "{{count}} 个文件已更改", + "ui.sessionTurn.turnChanges.undo": "撤销", + "ui.sessionTurn.turnChanges.undoConfirm": "确认撤销?", + "ui.sessionTurn.turnChanges.redo": "重做", + "ui.sessionTurn.turnChanges.redoConfirm": "确认重做?", + "ui.sessionTurn.turnChanges.status.added": "已添加", + "ui.sessionTurn.turnChanges.status.updated": "已更新", + "ui.sessionTurn.turnChanges.status.deleted": "已删除", + "ui.sessionTurn.turnChanges.openFile": "打开文件", + "ui.sessionTurn.turnChanges.showInFolder": "在文件夹中显示", "ui.sessionTurn.retry.retrying": "重试中", "ui.sessionTurn.retry.inSeconds": "{{seconds}} 秒后", From 0b4e4973e38daa198c568507b174416d8996a110 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 22:22:33 +0800 Subject: [PATCH 08/17] refactor: make turn changes an effect service --- packages/app/src/i18n/en.ts | 2 + packages/app/src/i18n/zh.ts | 2 + .../src/pages/session/message-timeline.tsx | 30 ++- packages/opencode/src/effect/app-runtime.ts | 2 + .../opencode/src/server/instance/session.ts | 14 +- packages/opencode/src/session/processor.ts | 5 +- packages/opencode/src/session/turn-change.ts | 232 ++++++++++++++---- packages/opencode/src/tool/apply_patch.ts | 7 +- packages/opencode/src/tool/edit.ts | 3 +- packages/opencode/src/tool/registry.ts | 4 +- packages/opencode/src/tool/write.ts | 3 +- .../opencode/test/session/compaction.test.ts | 3 +- .../test/session/processor-effect.test.ts | 2 + .../test/session/prompt-effect.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + .../opencode/test/session/turn-change.test.ts | 25 ++ .../opencode/test/tool/apply_patch.test.ts | 4 +- packages/opencode/test/tool/edit.test.ts | 2 + packages/opencode/test/tool/write.test.ts | 2 + packages/ui/src/components/session-turn.css | 6 + packages/ui/src/components/session-turn.tsx | 7 + packages/ui/src/i18n/en.ts | 1 + packages/ui/src/i18n/zh.ts | 1 + 23 files changed, 300 insertions(+), 61 deletions(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index dd043974a..8b1a6a21e 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -624,6 +624,8 @@ export const dict = { "This turn includes a file without restore data, so PawWork did not change anything.", "session.turnChange.blocked.permissionDenied": "PawWork does not have permission to change this file.", "session.turnChange.blocked.generic": "PawWork could not apply this change.", + "session.turnChange.blocked.files": "Affected: {{files}}", + "session.turnChange.blocked.more": ", +{{count}} more", "session.context.addToContext": "Add {{selection}} to context", "session.todo.title": "Todos", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index d4d210220..c9d9f4e26 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -585,6 +585,8 @@ export const dict = { "session.turnChange.blocked.unsupportedSize": "此轮包含没有恢复数据的文件,爪印没有修改任何内容。", "session.turnChange.blocked.permissionDenied": "爪印没有权限修改此文件。", "session.turnChange.blocked.generic": "爪印无法应用此更改。", + "session.turnChange.blocked.files": "涉及:{{files}}", + "session.turnChange.blocked.more": ",另有 {{count}} 个", "session.context.addToContext": "将 {{selection}} 添加到上下文", "session.todo.title": "待办事项", "session.todo.collapse": "折叠", diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 056851c98..78c0139bc 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -66,6 +66,8 @@ type TurnChangeDisplay = { messageID: string undoAvailable: boolean redoAvailable: boolean + truncated?: boolean + omittedCount?: number files: Array<{ path: string openPath?: string @@ -76,6 +78,7 @@ type TurnChangeDisplay = { sensitive?: boolean binary?: boolean large?: boolean + restoreAvailable?: boolean expandable: boolean }> } @@ -325,6 +328,24 @@ export function MessageTimeline(props: { } } + const blockedDescription = (body: any) => { + const base = + body?.reason === "conflict" + ? language.t("session.turnChange.blocked.conflict") + : body?.reason === "unsupported_size" + ? language.t("session.turnChange.blocked.unsupportedSize") + : body?.reason === "permission_denied" + ? language.t("session.turnChange.blocked.permissionDenied") + : language.t("session.turnChange.blocked.generic") + const files = Array.isArray(body?.files) + ? body.files.filter((file: any) => typeof file?.path === "string").map((file: any) => file.path as string) + : [] + if (!files.length) return base + const visible = files.slice(0, 3).join(", ") + const rest = files.length > 3 ? language.t("session.turnChange.blocked.more", { count: files.length - 3 }) : "" + return `${base} ${language.t("session.turnChange.blocked.files", { files: `${visible}${rest}` })}` + } + const turnChangeFetch = async ( messageID: string, action?: "undo" | "redo", @@ -352,14 +373,7 @@ export function MessageTimeline(props: { action === "undo" ? language.t("session.turnChange.undoBlocked") : language.t("session.turnChange.redoBlocked"), - description: - body?.reason === "conflict" - ? language.t("session.turnChange.blocked.conflict") - : body?.reason === "unsupported_size" - ? language.t("session.turnChange.blocked.unsupportedSize") - : body?.reason === "permission_denied" - ? language.t("session.turnChange.blocked.permissionDenied") - : language.t("session.turnChange.blocked.generic"), + description: blockedDescription(body), variant: "error", }) return turnChanges[messageID] ?? undefined diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index d9ce9ef6d..d0a3650e8 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -29,6 +29,7 @@ import { SessionProcessor } from "@/session/processor" import { SessionCompaction } from "@/session/compaction" import { SessionRevert } from "@/session/revert" import { SessionSummary } from "@/session/summary" +import { TurnChange } from "@/session/turn-change" import { SessionPrompt } from "@/session/prompt" import { Instruction } from "@/session/instruction" import { LLM } from "@/session/llm" @@ -80,6 +81,7 @@ export const AppLayer = Layer.mergeAll( SessionCompaction.defaultLayer, SessionRevert.defaultLayer, SessionSummary.defaultLayer, + TurnChange.defaultLayer, SessionPrompt.defaultLayer, Instruction.defaultLayer, LLM.defaultLayer, diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 0110f612c..0d2973e61 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -589,7 +589,13 @@ export const SessionRoutes = lazy(() => ), async (c) => { const params = c.req.valid("param") - return c.json(TurnChange.get(params) ?? null) + const result = await AppRuntime.runPromise( + Effect.gen(function* () { + const turnChange = yield* TurnChange.Service + return yield* turnChange.get(params) + }), + ) + return c.json(result ?? null) }, ) .post( @@ -621,8 +627,9 @@ export const SessionRoutes = lazy(() => const result = await AppRuntime.runPromise( Effect.gen(function* () { const state = yield* SessionRunState.Service + const turnChange = yield* TurnChange.Service yield* state.assertNotBusy(params.sessionID) - const result = yield* Effect.promise(() => TurnChange.undo(params)) + const result = yield* turnChange.undo(params) if (result.status === "applied") yield* publishTurnChangeFiles(result.display, "undo") return result }), @@ -659,8 +666,9 @@ export const SessionRoutes = lazy(() => const result = await AppRuntime.runPromise( Effect.gen(function* () { const state = yield* SessionRunState.Service + const turnChange = yield* TurnChange.Service yield* state.assertNotBusy(params.sessionID) - const result = yield* Effect.promise(() => TurnChange.redo(params)) + const result = yield* turnChange.redo(params) if (result.status === "applied") yield* publishTurnChangeFiles(result.display, "redo") return result }), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 94f9593ee..bd020fd83 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -122,6 +122,7 @@ export const layer: Layer.Layer< | Plugin.Service | SessionSummary.Service | SessionStatus.Service + | TurnChange.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -134,6 +135,7 @@ export const layer: Layer.Layer< const summary = yield* SessionSummary.Service const scope = yield* Scope.Scope const status = yield* SessionStatus.Service + const turnChange = yield* TurnChange.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -736,7 +738,7 @@ export const layer: Layer.Layer< ctx.toolcalls = {} ctx.assistantMessage.time.completed = Date.now() yield* session.updateMessage(ctx.assistantMessage) - TurnChange.finalize({ sessionID: ctx.sessionID, messageID: ctx.assistantMessage.id }) + yield* turnChange.finalize({ sessionID: ctx.sessionID, messageID: ctx.assistantMessage.id }) }) const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) { @@ -986,6 +988,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionStatus.defaultLayer), + Layer.provide(TurnChange.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), ), diff --git a/packages/opencode/src/session/turn-change.ts b/packages/opencode/src/session/turn-change.ts index 5e3618932..0ad921cda 100644 --- a/packages/opencode/src/session/turn-change.ts +++ b/packages/opencode/src/session/turn-change.ts @@ -13,8 +13,10 @@ import { trimDiff } from "@/tool/edit" import { Global } from "@/global" import * as Bom from "@/util/bom" import { Log } from "@opencode-ai/core/util/log" +import { Context, Effect, Layer } from "effect" +import { makeRuntime } from "@/effect/run-service" -type FileState = +export type FileState = | { exists: false; content?: undefined; hash?: string; restorable?: boolean; bom?: boolean; large?: boolean; binary?: boolean } | { exists: true; content?: string; hash?: string; restorable?: boolean; bom?: boolean; large?: boolean; binary?: boolean } type Status = "added" | "modified" | "deleted" @@ -39,9 +41,27 @@ export type Display = { messageID: MessageID undoAvailable: boolean redoAvailable: boolean + truncated?: boolean + omittedCount?: number files: DisplayFile[] } +export type RecordWriteInput = { + sessionID: SessionID + messageID: MessageID + path: string + before: FileState + after: FileState +} + +export type MutationResult = + | { status: "applied"; display: Display } + | { + status: "blocked" + reason: "conflict" | "restore_missing" | "permission_denied" | "unsupported_size" | "write_failed" + files: Array<{ path: string; reason: string }> + } + type RestoreFile = { path: string displayPath: string @@ -49,6 +69,11 @@ type RestoreFile = { after: FileState } +type RestoreOverflow = { + overflow: true + omittedCount: number +} + type RestoreRow = { session_id: SessionID message_id: MessageID @@ -58,18 +83,14 @@ type RestoreRow = { finalized: boolean } -type MutationResult = - | { status: "applied"; display: Display } - | { - status: "blocked" - reason: "conflict" | "restore_missing" | "permission_denied" | "unsupported_size" | "write_failed" - files: Array<{ path: string; reason: string }> - } +type RestoreTableRow = Omit & { + data: RestoreFile | RestoreOverflow +} const DISPLAY_LIMIT = 2 * 1024 * 1024 const RESTORE_LIMIT = 2 * 1024 * 1024 const MAX_FILES = 200 -const locks = new Map>() +const OVERFLOW_PATH = "__pawwork_turn_change_overflow__" const log = Log.create({ service: "session.turn-change" }) class RestoreConflictError extends Error { @@ -185,15 +206,36 @@ function toDisplay(file: RestoreFile): DisplayFile | undefined { } } +function isOverflow(data: RestoreFile | RestoreOverflow): data is RestoreOverflow { + return "overflow" in data && data.overflow === true +} + function rows(sessionID: SessionID, messageID: MessageID) { - return Database.use((db) => + const result = Database.use((db) => db .select() .from(TurnChangeRestoreTable) .where(and(eq(TurnChangeRestoreTable.session_id, sessionID), eq(TurnChangeRestoreTable.message_id, messageID))) .orderBy(TurnChangeRestoreTable.position) .all(), - ) as RestoreRow[] + ) as RestoreTableRow[] + return result.filter((row): row is RestoreRow => !isOverflow(row.data)) +} + +function overflowRow(sessionID: SessionID, messageID: MessageID) { + return Database.use((db) => + db + .select() + .from(TurnChangeRestoreTable) + .where( + and( + eq(TurnChangeRestoreTable.session_id, sessionID), + eq(TurnChangeRestoreTable.message_id, messageID), + eq(TurnChangeRestoreTable.file_path, OVERFLOW_PATH), + ), + ) + .get(), + ) as RestoreTableRow | undefined } function displayRow(sessionID: SessionID, messageID: MessageID) { @@ -274,6 +316,8 @@ export namespace TurnChange { messageID: MessageID.zod, undoAvailable: z.boolean(), redoAvailable: z.boolean(), + truncated: z.boolean().optional(), + omittedCount: z.number().optional(), files: z.array(DisplayFileSchema), }) @@ -329,13 +373,46 @@ export namespace TurnChange { return Database.use((db) => db.select().from(TurnChangeRestoreTable).where(restoreWhere(input)).get()) as RestoreRow | undefined } - export function recordWrite(input: { - sessionID: SessionID - messageID: MessageID - path: string - before: FileState - after: FileState - }) { + type ServiceState = { + locks: Map> + } + + function recordOverflow(input: { sessionID: SessionID; messageID: MessageID }) { + const current = overflowRow(input.sessionID, input.messageID) + const time = now() + const currentData = current?.data + const omittedCount = (currentData && isOverflow(currentData) ? currentData.omittedCount : 0) + 1 + Database.use((db) => + db + .insert(TurnChangeRestoreTable) + .values({ + session_id: input.sessionID, + message_id: input.messageID, + file_path: OVERFLOW_PATH, + position: MAX_FILES, + data: { overflow: true, omittedCount } satisfies RestoreOverflow, + finalized: false, + time_created: time, + time_updated: time, + }) + .onConflictDoUpdate({ + target: [TurnChangeRestoreTable.session_id, TurnChangeRestoreTable.message_id, TurnChangeRestoreTable.file_path], + set: { + data: { overflow: true, omittedCount } satisfies RestoreOverflow, + time_updated: time, + }, + }) + .run(), + ) + if (omittedCount === 1) + log.warn("turn change file limit reached", { + sessionID: input.sessionID, + messageID: input.messageID, + omittedCount, + }) + } + + function recordWriteInternal(input: RecordWriteInput) { if (!input.messageID) return try { const existing = getRestore(input) @@ -359,7 +436,10 @@ export namespace TurnChange { } const position = nextPosition(input.sessionID, input.messageID) - if (position >= MAX_FILES) return + if (position >= MAX_FILES) { + recordOverflow(input) + return + } try { Database.use((db) => db @@ -379,7 +459,14 @@ export namespace TurnChange { } catch { try { const current = getRestore(input) - if (!current) return + if (!current) { + log.warn("failed to record turn change restore row after insert conflict", { + sessionID: input.sessionID, + messageID: input.messageID, + error: "missing_restore_row", + }) + return + } const retryData: RestoreFile = { ...data, before: current.data.before, @@ -392,27 +479,41 @@ export namespace TurnChange { .run(), ) } catch { + log.warn("failed to update turn change restore row after insert conflict", { + sessionID: input.sessionID, + messageID: input.messageID, + error: "update_failed", + }) return } } } catch { + log.warn("failed to record turn change", { + sessionID: input.sessionID, + messageID: input.messageID, + error: "record_failed", + }) return } } - export function finalize(input: { sessionID: SessionID; messageID: MessageID }) { + function finalizeInternal(input: { sessionID: SessionID; messageID: MessageID }) { try { const files = rows(input.sessionID, input.messageID) if (!files.length) return const displayFiles = files.map((row) => toDisplay(row.data)).filter(Boolean) as DisplayFile[] if (!displayFiles.length) return const time = now() + const overflow = overflowRow(input.sessionID, input.messageID) + const overflowData = overflow?.data + const omittedCount = overflowData && isOverflow(overflowData) ? overflowData.omittedCount : 0 const display: Display = { sessionID: input.sessionID, turnID: input.messageID, messageID: input.messageID, undoAvailable: true, redoAvailable: false, + ...(omittedCount > 0 ? { truncated: true, omittedCount } : {}), files: displayFiles, } Database.transaction((db) => { @@ -463,7 +564,7 @@ export namespace TurnChange { } } - export function get(input: { sessionID: SessionID; messageID: MessageID }) { + function getInternal(input: { sessionID: SessionID; messageID: MessageID }) { const row = displayRow(input.sessionID, input.messageID) if (!row) return return withOpenPaths(withAvailability(row.data, row.state), rows(input.sessionID, input.messageID)) @@ -539,28 +640,77 @@ export namespace TurnChange { return { status: "applied", display: nextDisplay } } - export function undo(input: { sessionID: SessionID; messageID: MessageID }) { - return locked(input.sessionID, () => mutate({ ...input, mode: "undo" })) + async function locked(state: ServiceState, sessionID: SessionID, fn: () => Promise) { + const previous = state.locks.get(sessionID) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const queued = previous.then(() => current) + state.locks.set(sessionID, queued) + await previous + try { + return await fn() + } finally { + release() + if (state.locks.get(sessionID) === queued) state.locks.delete(sessionID) + } } - export function redo(input: { sessionID: SessionID; messageID: MessageID }) { - return locked(input.sessionID, () => mutate({ ...input, mode: "redo" })) + export interface Interface { + readonly recordWrite: (input: RecordWriteInput) => Effect.Effect + readonly finalize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect + readonly get: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect + readonly undo: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect + readonly redo: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect } -} -async function locked(sessionID: SessionID, fn: () => Promise) { - const previous = locks.get(sessionID) ?? Promise.resolve() - let release!: () => void - const current = new Promise((resolve) => { - release = resolve - }) - const queued = previous.then(() => current) - locks.set(sessionID, queued) - await previous - try { - return await fn() - } finally { - release() - if (locks.get(sessionID) === queued) locks.delete(sessionID) + export class Service extends Context.Service()("@pawwork/TurnChange") {} + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const state: ServiceState = { locks: new Map() } + return Service.of({ + recordWrite: Effect.fn("TurnChange.recordWrite")(function* (input) { + recordWriteInternal(input) + }), + finalize: Effect.fn("TurnChange.finalize")(function* (input) { + return finalizeInternal(input) + }), + get: Effect.fn("TurnChange.get")(function* (input) { + return getInternal(input) + }), + undo: Effect.fn("TurnChange.undo")(function* (input) { + return yield* Effect.promise(() => locked(state, input.sessionID, () => mutate({ ...input, mode: "undo" }))) + }), + redo: Effect.fn("TurnChange.redo")(function* (input) { + return yield* Effect.promise(() => locked(state, input.sessionID, () => mutate({ ...input, mode: "redo" }))) + }), + }) + }), + ) + + export const defaultLayer = layer + const runtime = makeRuntime(Service, defaultLayer) + + export function recordWrite(input: RecordWriteInput) { + return runtime.runSync((svc) => svc.recordWrite(input)) + } + + export function finalize(input: { sessionID: SessionID; messageID: MessageID }) { + return runtime.runSync((svc) => svc.finalize(input)) + } + + export function get(input: { sessionID: SessionID; messageID: MessageID }) { + return runtime.runSync((svc) => svc.get(input)) + } + + export function undo(input: { sessionID: SessionID; messageID: MessageID }) { + return runtime.runPromise((svc) => svc.undo(input)) + } + + export function redo(input: { sessionID: SessionID; messageID: MessageID }) { + return runtime.runPromise((svc) => svc.redo(input)) } } diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index a13cc98ad..ef46b56aa 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -41,6 +41,7 @@ export const ApplyPatchTool = Tool.define( const afs = yield* AppFileSystem.Service const format = yield* Format.Service const bus = yield* Bus.Service + const turnChange = yield* TurnChange.Service const run = Effect.fn("ApplyPatchTool.execute")(function* ( params: Schema.Schema.Type, @@ -326,14 +327,14 @@ export const ApplyPatchTool = Tool.define( } if (change.type === "move" && change.movePath) { - TurnChange.recordWrite({ + yield* turnChange.recordWrite({ sessionID: ctx.sessionID, messageID: ctx.messageID, path: change.filePath, before: { exists: true, content: change.oldContent, bom: change.beforeBom }, after: { exists: false }, }) - TurnChange.recordWrite({ + yield* turnChange.recordWrite({ sessionID: ctx.sessionID, messageID: ctx.messageID, path: change.movePath, @@ -343,7 +344,7 @@ export const ApplyPatchTool = Tool.define( after: { exists: true, content: change.newContent, bom: change.bom }, }) } else { - TurnChange.recordWrite({ + yield* turnChange.recordWrite({ sessionID: ctx.sessionID, messageID: ctx.messageID, path: change.filePath, diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 24a678c96..f4b07f1ea 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -64,6 +64,7 @@ export const EditTool = Tool.define( const afs = yield* AppFileSystem.Service const format = yield* Format.Service const bus = yield* Bus.Service + const turnChange = yield* TurnChange.Service return { description: DESCRIPTION, @@ -246,7 +247,7 @@ export const EditTool = Tool.define( ...(sensitive && bomDiscarded ? { bomDiscarded: true } : {}), }, }) - TurnChange.recordWrite({ + yield* turnChange.recordWrite({ sessionID: ctx.sessionID, messageID: ctx.messageID, path: filePath, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 9a1ecb01d..cb7f48d39 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -48,6 +48,7 @@ import { makeRuntime } from "@/effect/run-service" import { Env } from "../env" import { Question } from "../question" import { Todo } from "../session/todo" +import { TurnChange } from "../session/turn-change" import { LSP } from "../lsp" import { Instruction } from "../session/instruction" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -95,6 +96,7 @@ export namespace ToolRegistry { | Plugin.Service | Question.Service | Todo.Service + | TurnChange.Service | Agent.Service | Skill.Service | Session.Service @@ -407,7 +409,7 @@ export namespace ToolRegistry { Layer.provide(Config.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(Question.defaultLayer), - Layer.provide(Todo.defaultLayer), + Layer.provide(Layer.mergeAll(Todo.defaultLayer, TurnChange.defaultLayer)), Layer.provide(Skill.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 1dbe0a315..bf18c38c7 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -33,6 +33,7 @@ export const WriteTool = Tool.define( const fs = yield* AppFileSystem.Service const bus = yield* Bus.Service const format = yield* Format.Service + const turnChange = yield* TurnChange.Service return { description: DESCRIPTION, @@ -82,7 +83,7 @@ export const WriteTool = Tool.define( diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, finalContent)) } yield* bus.publish(File.Event.Edited, { file: filepath }) - TurnChange.recordWrite({ + yield* turnChange.recordWrite({ sessionID: ctx.sessionID, messageID: ctx.messageID, path: filepath, diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 060f16843..92b251f24 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -19,6 +19,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" +import { TurnChange } from "../../src/session/turn-change" import { ModelID, ProviderID } from "../../src/provider/schema" import type { Provider } from "../../src/provider" import * as SessionProcessorModule from "../../src/session/processor" @@ -309,7 +310,7 @@ function inputText(messages: LLM.StreamInput["messages"] | undefined) { function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake(), config = Config.defaultLayer) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) + const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(TurnChange.defaultLayer)) return ManagedRuntime.make( Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( Layer.provide(provider.layer), diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 4a11e3b33..f873aa60f 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -17,6 +17,7 @@ import { SessionProcessor } from "../../src/session/processor" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" +import { TurnChange } from "../../src/session/turn-change" import { Snapshot } from "../../src/snapshot" import { Log } from "../../src/util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -165,6 +166,7 @@ const deps = Layer.mergeAll( Config.defaultLayer, LLM.defaultLayer, Provider.defaultLayer, + TurnChange.defaultLayer, status, ).pipe(Layer.provideMerge(infra)) const env = Layer.mergeAll( diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 7f9280731..07dd24ede 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -32,6 +32,7 @@ import { SessionRunState } from "../../src/session/run-state" import { SubagentRun } from "../../src/session/subagent-run" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { TurnChange } from "../../src/session/turn-change" import { Skill } from "../../src/skill" import { Settings } from "../../src/settings" import { SystemPrompt } from "../../src/session/system" @@ -177,6 +178,7 @@ function makeHttp() { lsp, mcp, AppFileSystem.defaultLayer, + TurnChange.defaultLayer, status, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 09633e08a..16c1d9b59 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -50,6 +50,7 @@ import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" import { SessionRunState } from "../../src/session/run-state" import { SessionStatus } from "../../src/session/status" +import { TurnChange } from "../../src/session/turn-change" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool/registry" import { WebSearchAuth } from "../../src/tool/websearch-auth" @@ -125,6 +126,7 @@ function makeHttp() { lsp, mcp, AppFileSystem.defaultLayer, + TurnChange.defaultLayer, status, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) diff --git a/packages/opencode/test/session/turn-change.test.ts b/packages/opencode/test/session/turn-change.test.ts index 96ce22ee5..8b9afda2e 100644 --- a/packages/opencode/test/session/turn-change.test.ts +++ b/packages/opencode/test/session/turn-change.test.ts @@ -107,6 +107,31 @@ describe("TurnChange", () => { }) }) + test("finalizes file-limit overflow as an explicit truncated display", async () => { + await resetDatabase() + await using fixture = await tmpdir() + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const turn = await createTurn() + for (let index = 0; index < 201; index++) { + TurnChange.recordWrite({ + ...turn, + path: path.join(fixture.path, `file-${index}.txt`), + before: { exists: false }, + after: { exists: true, content: `${index}\n` }, + }) + } + + const display = TurnChange.finalize(turn) + + expect(display?.files).toHaveLength(200) + expect(display?.truncated).toBe(true) + expect(display?.omittedCount).toBe(1) + }, + }) + }) + test("finalizes large files as status-only and blocks restore when target content is unavailable", async () => { await resetDatabase() await using fixture = await tmpdir() diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index b75c5e350..3801c52be 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -12,6 +12,7 @@ import { Bus } from "../../src/bus" import { Truncate } from "../../src/tool/truncate" import { tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" +import { TurnChange } from "../../src/session/turn-change" const runtime = ManagedRuntime.make( Layer.mergeAll( @@ -21,6 +22,7 @@ const runtime = ManagedRuntime.make( Bus.layer, Truncate.defaultLayer, Agent.defaultLayer, + TurnChange.defaultLayer, ), ) @@ -52,7 +54,7 @@ type ToolCtx = typeof baseCtx & { const execute = async (params: { patchText: string }, ctx: ToolCtx) => { const info = await runtime.runPromise(ApplyPatchTool) const tool = await runtime.runPromise(info.init()) - return Effect.runPromise(tool.execute(params, ctx)) + return runtime.runPromise(tool.execute(params, ctx)) } const makeCtx = () => { diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 0d923ba51..d036c181c 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -12,6 +12,7 @@ import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Truncate } from "../../src/tool/truncate" import { SessionID, MessageID } from "../../src/session/schema" +import { TurnChange } from "../../src/session/turn-change" import { FileWatcher } from "../../src/file/watcher" import * as Tool from "../../src/tool/tool" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" @@ -61,6 +62,7 @@ const it = testEffect( Truncate.defaultLayer, Agent.defaultLayer, CrossSpawnSpawner.defaultLayer, + TurnChange.defaultLayer, ), ) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 60422c1c3..f38a09f26 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -12,6 +12,7 @@ import { Truncate } from "../../src/tool/truncate" import * as Tool from "../../src/tool/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" +import { TurnChange } from "../../src/session/turn-change" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -54,6 +55,7 @@ const it = testEffect( CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, + TurnChange.defaultLayer, ), ) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index c5d4ec60a..7bae321a1 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -136,6 +136,12 @@ font-variant-numeric: tabular-nums; } + [data-slot="session-turn-changes-omitted"] { + color: var(--text-muted); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-regular); + } + [data-slot="session-turn-changes-action"] { width: 72px; height: 28px; diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 527f6840a..a9c46c728 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -164,6 +164,8 @@ type TurnChangeDisplay = { messageID: string undoAvailable: boolean redoAvailable: boolean + truncated?: boolean + omittedCount?: number files: TurnChangeFile[] } @@ -534,6 +536,11 @@ export function SessionTurn( +{turnAdditions()} -{turnDeletions()} + 0}> + + {i18n.t("ui.sessionTurn.turnChanges.omitted", { count: turnChange()?.omittedCount ?? 0 })} + +
- 0 && !working()}> +
From 4c69108aaf4053d9b759b2e0b98431641714e509 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 23:36:43 +0800 Subject: [PATCH 17/17] fix: guard turn change actions --- .../ui/src/components/session-turn-changes.ts | 15 +++++++++++ .../session-turn-turn-changes.test.ts | 27 ++++++++++++++++++- packages/ui/src/components/session-turn.tsx | 25 +++++++++-------- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/session-turn-changes.ts b/packages/ui/src/components/session-turn-changes.ts index b9e4b0ec9..965c17e33 100644 --- a/packages/ui/src/components/session-turn-changes.ts +++ b/packages/ui/src/components/session-turn-changes.ts @@ -26,3 +26,18 @@ export type TurnChangeDisplay = { export function hasVisibleTurnChanges(display: TurnChangeDisplay | null | undefined) { return !!display && (display.files.length > 0 || !!display.truncated) } + +export function turnChangeAction(display: TurnChangeDisplay | null | undefined): "undo" | "redo" | undefined { + if (display?.undoAvailable) return "undo" + if (display?.redoAvailable) return "redo" +} + +export function hasTurnChangeActionHandler( + display: TurnChangeDisplay | null | undefined, + actions: { undo?: unknown; redo?: unknown } | null | undefined, +) { + const action = turnChangeAction(display) + if (action === "undo") return typeof actions?.undo === "function" + if (action === "redo") return typeof actions?.redo === "function" + return false +} diff --git a/packages/ui/src/components/session-turn-turn-changes.test.ts b/packages/ui/src/components/session-turn-turn-changes.test.ts index c6b6e7e71..891658d3d 100644 --- a/packages/ui/src/components/session-turn-turn-changes.test.ts +++ b/packages/ui/src/components/session-turn-turn-changes.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test" -import { hasVisibleTurnChanges, type TurnChangeDisplay } from "./session-turn-changes" +import { + hasTurnChangeActionHandler, + hasVisibleTurnChanges, + turnChangeAction, + type TurnChangeDisplay, +} from "./session-turn-changes" const base = { sessionID: "ses", @@ -29,4 +34,24 @@ describe("session turn changes", () => { }), ).toBe(false) }) + + test("requires the matching handler before showing an undo or redo action", () => { + const undoDisplay: TurnChangeDisplay = { + ...base, + undoAvailable: true, + files: [{ path: "file.txt", status: "modified", expandable: false }], + } + const redoDisplay: TurnChangeDisplay = { + ...base, + redoAvailable: true, + files: [{ path: "file.txt", status: "modified", expandable: false }], + } + + expect(turnChangeAction(undoDisplay)).toBe("undo") + expect(hasTurnChangeActionHandler(undoDisplay, {})).toBe(false) + expect(hasTurnChangeActionHandler(undoDisplay, { undo: () => undefined })).toBe(true) + expect(turnChangeAction(redoDisplay)).toBe("redo") + expect(hasTurnChangeActionHandler(redoDisplay, { undo: () => undefined })).toBe(false) + expect(hasTurnChangeActionHandler(redoDisplay, { redo: () => undefined })).toBe(true) + }) }) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 4a4d44d0f..bdbea1f76 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -26,7 +26,13 @@ import { TextReveal } from "./text-reveal" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" import { normalize } from "./session-diff" -import { hasVisibleTurnChanges, type TurnChangeDisplay, type TurnChangeFile } from "./session-turn-changes" +import { + hasTurnChangeActionHandler, + hasVisibleTurnChanges, + turnChangeAction, + type TurnChangeDisplay, + type TurnChangeFile, +} from "./session-turn-changes" function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) @@ -323,8 +329,8 @@ export function SessionTurn( const current = turnChange() const id = current?.messageID if (!id) return - const action = current.undoAvailable ? "undo" : current.redoAvailable ? "redo" : undefined - if (!action) return + const action = turnChangeAction(current) + if (!action || !hasTurnChangeActionHandler(current, props.turnChangeActions)) return if (!primeConfirm(action)) return resetConfirm() if (action === "undo") await props.turnChangeActions?.undo?.(id) @@ -332,7 +338,7 @@ export function SessionTurn( } const turnActionLabel = createMemo(() => { const current = turnChange() - const action = current?.undoAvailable ? "undo" : current?.redoAvailable ? "redo" : undefined + const action = turnChangeAction(current) if (!action) return "" const base = action === "undo" ? i18n.t("ui.sessionTurn.turnChanges.undo") : i18n.t("ui.sessionTurn.turnChanges.redo") return confirmAction() === action @@ -346,13 +352,6 @@ export function SessionTurn( if (status === "deleted") return i18n.t("ui.sessionTurn.turnChanges.status.deleted") return i18n.t("ui.sessionTurn.turnChanges.status.updated") } - const parentPath = (value: string) => { - const normalized = value.replaceAll("\\", "/") - const index = normalized.lastIndexOf("/") - if (index <= 0) return value - return value.slice(0, index) - } - const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError")) const divider = createMemo(() => { if (compaction()) return i18n.t("ui.messagePart.compaction") @@ -518,7 +517,7 @@ export function SessionTurn(
- +