diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index ac3d00ecd7..fbfec27a0f 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -10,6 +10,7 @@ import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller" import { useNewSessionCommands } from "./new-session/use-new-session-commands" import { postRouteInfo } from "@/utils/amicode-route-info" +import { sessionContextMessage } from "@/utils/amicode-session-relay" import { useAmicodeCommands } from "@/pages/session/use-amicode-commands" /** The draft-only V2 session page. Submitting promotes the draft into a real session. */ @@ -46,6 +47,7 @@ export default function NewSessionPage() { }) onMount(() => { + if (window.parent !== window) window.parent.postMessage(sessionContextMessage(), "*") // amicode(deck): label the framing pane tab; the draftId rides the search // so the shell can rebuild this pane with its draft text intact. postRouteInfo(`${location.pathname}${location.search}`, "New session") @@ -55,6 +57,9 @@ export default function NewSessionPage() { } window.addEventListener("message", onPreviewFile) onCleanup(() => window.removeEventListener("message", onPreviewFile)) + onCleanup(() => { + if (window.parent !== window) window.parent.postMessage(sessionContextMessage(), "*") + }) }) const ready = Promise.resolve() const [suspendUntilPromptReady] = createResource( diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 89ebdd021d..3883e10e3b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import type { FilePart, Project, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" +import type { FilePart, Project, SessionAssessedDiffResponse, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createQuery, skipToken, useMutation } from "@tanstack/solid-query" @@ -103,6 +103,7 @@ import { authTokenFromCredentials } from "@/utils/server" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { postRouteInfo } from "@/utils/amicode-route-info" +import { assessedDiffInvalidation, sessionContextMessage } from "@/utils/amicode-session-relay" import { notifyProjectSelected } from "@/utils/amicode-workspace-projects" import { setSessionCopyProvider } from "@/utils/global-clipboard" import { serializeSession } from "@/utils/serialize-session" @@ -387,6 +388,15 @@ export default function Page() { const sessionOwnership = createSessionOwnership(sessionKey) const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) + createEffect(() => { + if (window.parent === window) return + window.parent.postMessage(sessionContextMessage(params.id), "*") + }) + onCleanup(() => { + if (window.parent === window) return + window.parent.postMessage(sessionContextMessage(), "*") + }) + createEffect(() => { if (!prompt.ready()) return untrack(() => { @@ -779,6 +789,17 @@ export default function Page() { window.addEventListener("message", onFsDiffInvalidate) onCleanup(() => window.removeEventListener("message", onFsDiffInvalidate)) + // Server-owned external references are invalidated opaquely: the app keeps + // no client-side lifecycle or path state and refetches assessed detail. + const onAssessedDiffInvalidate = (e: MessageEvent) => { + if (!assessedDiffInvalidation(e.data)) return + const sessionID = params.id + if (!sessionID) return + sync().set("diff_version", sessionID, (v: number | undefined) => (v ?? 0) + 1) + } + window.addEventListener("message", onAssessedDiffInvalidate) + onCleanup(() => window.removeEventListener("message", onAssessedDiffInvalidate)) + // Refetch when the session transitions to idle (assistant finished, snapshot taken) // or when a file-editing tool completes mid-turn (diff_version bumps) const sessionDiffVersion = () => { @@ -810,6 +831,25 @@ export default function Page() { : skipToken, } }) + const assessedExternalDiffQuery = createQuery(() => { + const sessionID = params.id + return { + queryKey: ["session-assessed-external-diff", params.id ?? "", sessionDiffVersion()] as const, + enabled: !!sessionID, + // Generated external patches are sensitive, one-response render data. + // Do not retain them in the persistent query cache or telemetry cache. + gcTime: 0, + staleTime: 0, + retry: false, + queryFn: sessionID + ? () => + sdk() + .client.session + .assessedDiff({ sessionID, directory: sdk().directory, patch: "true" }) + .then((result) => result.data) + : skipToken, + } + }) const reviewDiffs = createMemo(() => { // Shared path normalization — both sources must use the same function for dedup to work. const dir = sdk().directory @@ -846,10 +886,17 @@ export default function Page() { // --- Server diffs (authoritative for in-project files) --- const serverDiffs = sessionDiffQuery.data ?? [] const serverResponded = sessionDiffQuery.status === "success" || sessionDiffQuery.isPlaceholderData + // External metadata is never a current-diff fallback. It is visible only + // after this session's authenticated, server-assessed detail request settles. + const assessedDiffs = + assessedExternalDiffQuery.status === "success" + ? (assessedExternalDiffQuery.data?.assessments ?? []) + : [] return mergeServerAndToolDiffs({ serverDiffs, toolDiffs, + assessedDiffs, serverResponded, directory: dir, home, @@ -857,13 +904,11 @@ export default function Page() { }) }) - // #844: Send watch-files to the extension host whenever the file list changes. - // The FileWatcherBridge watches these paths for external create/change/delete. - // We resolve ~/... paths back to absolute for the OS-level watcher. + // #844: Preserve raw watch-files only for the server's in-worktree diff rows. + // External paths stay in the extension host behind opaque assessed references. createEffect(() => { - const diffs = reviewDiffs() const home = typeof globalThis.process !== "undefined" ? globalThis.process.env?.HOME : undefined - const files = diffs + const files = (sessionDiffQuery.data ?? []) .map((d) => { if (!d.file) return "" // Resolve ~/... back to absolute diff --git a/packages/app/src/pages/session/amicode-session-relay.integration.test.ts b/packages/app/src/pages/session/amicode-session-relay.integration.test.ts new file mode 100644 index 0000000000..ca53312ff9 --- /dev/null +++ b/packages/app/src/pages/session/amicode-session-relay.integration.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +const sessionSource = readFileSync(join(import.meta.dir, "..", "session.tsx"), "utf8") +const draftSource = readFileSync(join(import.meta.dir, "..", "new-session.tsx"), "utf8") + +describe("Amicode session relay routes", () => { + test("publishes current session context and clears it for draft and unmount", () => { + expect(sessionSource).toContain('window.parent.postMessage(sessionContextMessage(params.id), "*")') + expect(sessionSource).toContain('window.parent.postMessage(sessionContextMessage(), "*")') + expect(draftSource).toContain('window.parent.postMessage(sessionContextMessage(), "*")') + }) + + test("refetches opaque assessed detail without sending external paths to browser watching", () => { + const assessedHandler = sessionSource.slice( + sessionSource.indexOf("const onAssessedDiffInvalidate"), + sessionSource.indexOf('window.addEventListener("message", onAssessedDiffInvalidate)'), + ) + const legacyWatch = sessionSource.slice( + sessionSource.indexOf("// #844: Preserve raw watch-files"), + sessionSource.indexOf('window.parent.postMessage({ source: "amicode", kind: "watch-files", files }, "*")'), + ) + + expect(assessedHandler).toContain("if (!assessedDiffInvalidation(e.data)) return") + expect(assessedHandler).toContain('sync().set("diff_version", sessionID') + expect(assessedHandler).not.toContain("externalFileStatus") + expect(legacyWatch).toContain("const files = (sessionDiffQuery.data ?? [])") + expect(legacyWatch).not.toContain("reviewDiffs()") + }) +}) diff --git a/packages/app/src/pages/session/v2/accumulate-diffs.test.ts b/packages/app/src/pages/session/v2/accumulate-diffs.test.ts index 1089437ca2..612617d263 100644 --- a/packages/app/src/pages/session/v2/accumulate-diffs.test.ts +++ b/packages/app/src/pages/session/v2/accumulate-diffs.test.ts @@ -141,6 +141,99 @@ describe("mergeServerAndToolDiffs", () => { expect(files).toContain("~/other-project/bar.ts") }) + test("renders a settled external assessment instead of the completed tool patch", () => { + const result = mergeServerAndToolDiffs({ + serverDiffs: [], + toolDiffs: [diff("~/other-project/bar.ts", "tool-patch", 9, 4)], + assessedDiffs: [ + { + reference: "external_opaque", + file: "/Users/jj/other-project/bar.ts", + state: "changed" as const, + patch: "assessed-patch", + additions: 2, + deletions: 1, + }, + ], + serverResponded: true, + directory: DIR, + home: HOME, + }) + + expect(result).toEqual([diff("~/other-project/bar.ts", "assessed-patch", 2, 1)]) + }) + + test("does not promote an external tool patch while its assessment is absent or loading", () => { + const result = mergeServerAndToolDiffs({ + serverDiffs: [], + toolDiffs: [diff("~/other-project/bar.ts", "stale-tool-patch", 9, 4)], + assessedDiffs: [], + serverResponded: false, + directory: DIR, + home: HOME, + }) + + expect(result).toEqual([]) + }) + + test("does not render an unavailable external assessment", () => { + const result = mergeServerAndToolDiffs({ + serverDiffs: [], + toolDiffs: [diff("~/other-project/bar.ts", "stale-tool-patch", 9, 4)], + assessedDiffs: [ + { + reference: "external_opaque", + file: "/Users/jj/other-project/bar.ts", + state: "unavailable" as const, + }, + ], + serverResponded: true, + directory: DIR, + home: HOME, + }) + + expect(result).toEqual([]) + }) + + test("removes a zero-net external assessment and uses its server-derived lifecycle status", () => { + const unchanged = mergeServerAndToolDiffs({ + serverDiffs: [], + toolDiffs: [diff("~/other-project/bar.ts", "stale-tool-patch", 9, 4)], + assessedDiffs: [ + { + reference: "external_opaque", + file: "/Users/jj/other-project/bar.ts", + state: "unchanged" as const, + }, + ], + serverResponded: true, + directory: DIR, + home: HOME, + }) + expect(unchanged).toEqual([]) + + const deleted = mergeServerAndToolDiffs({ + serverDiffs: [], + toolDiffs: [], + assessedDiffs: [ + { + reference: "external_opaque", + file: "/Users/jj/other-project/bar.ts", + state: "changed" as const, + status: "added" as const, + patch: "assessed-delete", + additions: 0, + deletions: 1, + }, + ], + serverResponded: true, + directory: DIR, + home: HOME, + externalFileStatus: new Map([["~/other-project/bar.ts", "deleted" as const]]), + }) + expect(deleted).toEqual([diff("~/other-project/bar.ts", "assessed-delete", 0, 1, "added")]) + }) + test("in-project files come from the server, not tool metadata (server wins)", () => { const result = mergeServerAndToolDiffs({ serverDiffs: [diff("src/foo.ts", "server-patch", 10, 5)], @@ -183,10 +276,7 @@ describe("mergeServerAndToolDiffs", () => { test("fallback: all tool diffs shown when server has not responded", () => { const result = mergeServerAndToolDiffs({ serverDiffs: [], - toolDiffs: [ - diff("~/harmoniqs/amicode/src/foo.ts"), - diff("~/other/bar.ts"), - ], + toolDiffs: [diff("~/harmoniqs/amicode/src/foo.ts"), diff("~/other/bar.ts")], serverResponded: false, directory: DIR, home: HOME, @@ -348,12 +438,9 @@ describe("round-trip move: project → cross-project → back to project", () => // Step 2: file moved to opencode — rename map transforms tool diff const renames1 = new Map([["~/harmoniqs/amicode/test.md", "~/harmoniqs/opencode/test.md"]]) - const toolDiffsStep2 = applyRenames( - [diff("~/harmoniqs/amicode/test.md", "patch", 2, 0, "added")], - renames1, - ) + const toolDiffsStep2 = applyRenames([diff("~/harmoniqs/amicode/test.md", "patch", 2, 0, "added")], renames1) const step2 = mergeServerAndToolDiffs({ - serverDiffs: [], // server no longer has it (moved away) + serverDiffs: [], // server no longer has it (moved away) toolDiffs: toolDiffsStep2, serverResponded: true, directory: DIR, @@ -368,10 +455,7 @@ describe("round-trip move: project → cross-project → back to project", () => ["~/harmoniqs/amicode/test.md", "~/harmoniqs/amicode/test.md"], ["~/harmoniqs/opencode/test.md", "~/harmoniqs/amicode/test.md"], ]) - const toolDiffsStep3 = applyRenames( - [diff("~/harmoniqs/amicode/test.md", "patch", 2, 0, "added")], - renames2, - ) + const toolDiffsStep3 = applyRenames([diff("~/harmoniqs/amicode/test.md", "patch", 2, 0, "added")], renames2) // Tool diff is back at the in-project path expect(toolDiffsStep3[0].file).toBe("~/harmoniqs/amicode/test.md") @@ -389,13 +473,8 @@ describe("round-trip move: project → cross-project → back to project", () => test("file moved back DISAPPEARS when server has NOT refetched (the bug this fix addresses)", () => { // Rename map resolves to in-project, but server still has stale empty response - const renames = new Map([ - ["~/harmoniqs/amicode/test.md", "~/harmoniqs/amicode/test.md"], - ]) - const toolDiffs = applyRenames( - [diff("~/harmoniqs/amicode/test.md", "patch", 2, 0, "added")], - renames, - ) + const renames = new Map([["~/harmoniqs/amicode/test.md", "~/harmoniqs/amicode/test.md"]]) + const toolDiffs = applyRenames([diff("~/harmoniqs/amicode/test.md", "patch", 2, 0, "added")], renames) // Server hasn't refetched — stale empty response const result = mergeServerAndToolDiffs({ serverDiffs: [], diff --git a/packages/app/src/pages/session/v2/accumulate-diffs.ts b/packages/app/src/pages/session/v2/accumulate-diffs.ts index 0a6c52e6d9..ab686210b7 100644 --- a/packages/app/src/pages/session/v2/accumulate-diffs.ts +++ b/packages/app/src/pages/session/v2/accumulate-diffs.ts @@ -1,4 +1,4 @@ -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { SessionAssessedDiffResponse, SnapshotFileDiff } from "@opencode-ai/sdk/v2" export type ToolEditPart = { file: string @@ -21,6 +21,7 @@ export function toHomePath(p: string, home: string | undefined, prefix: string): export interface MergeOpts { serverDiffs: SnapshotFileDiff[] toolDiffs: Array + assessedDiffs?: AssessedExternalDiff[] serverResponded: boolean directory: string home: string | undefined @@ -31,22 +32,39 @@ export interface MergeOpts { externalFileStatus?: Map } +export type AssessedExternalDiff = SessionAssessedDiffResponse["assessments"][number] + /** * Merge server shadow-git diffs with tool-metadata diffs. * - * Server diffs are authoritative for in-project files. Tool-metadata diffs - * fill in cross-project files (outside the project directory) not covered - * by the server. In-project tool-metadata diffs that the server excluded - * (e.g. created + deleted = net zero) are dropped — the server's absence - * is the authority. + * Server diffs are authoritative for in-project files. Server-assessed + * external entries are the only external truth once the caller supplies an + * assessment result. In-project tool-metadata diffs that the server excluded + * (e.g. created + deleted = net zero) are dropped — the server's absence is + * the authority. * - * When serverResponded is false (initial load), all tool-metadata diffs - * are returned as a fallback. + * When serverResponded is false (initial load), tool metadata remains a + * fallback only for in-project files when assessed external data is supplied. */ export function mergeServerAndToolDiffs(opts: MergeOpts): Array { - const { serverDiffs, toolDiffs, serverResponded, directory, home, externalFileStatus } = opts + const { serverDiffs, toolDiffs, assessedDiffs, serverResponded, directory, home, externalFileStatus } = opts const prefix = home && directory.startsWith(home) ? "~" + directory.slice(home.length) : directory const projectPrefix = prefix + "/" + const assessedExternal = (assessedDiffs ?? []) + .filter((diff) => diff.state === "changed" && diff.patch !== undefined) + .map((diff) => { + const file = toHomePath(diff.file, home, prefix) + const status: "added" | "modified" | "deleted" = diff.status ?? "modified" + const additions = typeof diff.additions === "number" ? diff.additions : 0 + const deletions = typeof diff.deletions === "number" ? diff.deletions : 0 + return { + file, + patch: diff.patch, + additions, + deletions, + status, + } + }) if (serverDiffs.length > 0 || serverResponded) { const normalizedServerDiffs = serverDiffs @@ -57,21 +75,22 @@ export function mergeServerAndToolDiffs(opts: MergeOpts): Array d.file)) - // Only pass through tool-metadata diffs that are BOTH absent from the - // server set AND outside the project directory. In-project files trust - // the server's authority — if the server excluded them (created + deleted, - // or reverted), they should not leak through as phantom entries. - const crossProjectDiffs = toolDiffs + const legacyExternal = toolDiffs .filter((d) => !serverFiles.has(d.file) && !d.file.startsWith(projectPrefix)) .map((d) => { const override = externalFileStatus?.get(d.file) return override ? { ...d, status: override } : d }) - - return [...normalizedServerDiffs, ...crossProjectDiffs] + return [...normalizedServerDiffs, ...legacyExternal] } + // Tool metadata remains the initial fallback for in-worktree files only. + // External rows have no current-diff authority until the server assessment settles. + if (assessedDiffs) return [...toolDiffs.filter((d) => d.file.startsWith(projectPrefix)), ...assessedExternal] return toolDiffs } diff --git a/packages/app/src/utils/amicode-session-relay.test.ts b/packages/app/src/utils/amicode-session-relay.test.ts new file mode 100644 index 0000000000..5e50f73416 --- /dev/null +++ b/packages/app/src/utils/amicode-session-relay.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { assessedDiffInvalidation, sessionContextMessage } from "./amicode-session-relay" + +describe("Amicode session relay", () => { + test("publishes a live session and clears draft context", () => { + expect(sessionContextMessage("ses_live")).toEqual({ + source: "amicode", + kind: "session-context", + sessionID: "ses_live", + }) + expect(sessionContextMessage()).toEqual({ + source: "amicode", + kind: "session-context", + draft: true, + }) + }) + + test("accepts only opaque assessed-diff invalidations", () => { + expect( + assessedDiffInvalidation({ + source: "amicode", + kind: "assessed-diff-invalidate", + reference: "external_opaque", + revision: 7, + file: "/canonical/path", + patch: "secret patch", + expectedDigest: "secret digest", + }), + ).toEqual({ reference: "external_opaque", revision: 7 }) + expect( + assessedDiffInvalidation({ source: "amicode", kind: "assessed-diff-invalidate", reference: "external_opaque" }), + ).toBeUndefined() + }) +}) diff --git a/packages/app/src/utils/amicode-session-relay.ts b/packages/app/src/utils/amicode-session-relay.ts new file mode 100644 index 0000000000..7dae3c34ae --- /dev/null +++ b/packages/app/src/utils/amicode-session-relay.ts @@ -0,0 +1,27 @@ +export type SessionContextMessage = + | { source: "amicode"; kind: "session-context"; sessionID: string } + | { source: "amicode"; kind: "session-context"; draft: true } + +export type AssessedDiffInvalidation = { + reference: string + revision: number +} + +export function sessionContextMessage(sessionID?: string): SessionContextMessage { + if (sessionID) return { source: "amicode", kind: "session-context", sessionID } + return { source: "amicode", kind: "session-context", draft: true } +} + +export function assessedDiffInvalidation(message: unknown): AssessedDiffInvalidation | undefined { + if (!message || typeof message !== "object") return + const data = message as { source?: unknown; kind?: unknown; reference?: unknown; revision?: unknown } + if (data.source !== "amicode" || data.kind !== "assessed-diff-invalidate") return + if ( + typeof data.reference !== "string" || + data.reference === "" || + typeof data.revision !== "number" || + !Number.isInteger(data.revision) + ) + return + return { reference: data.reference, revision: data.revision } +} diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 5395a812f5..1a0a522bc5 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -1 +1,3 @@ +// Establish the isolated XDG paths before any harness dependency resolves Global.Path. +await import("../test/server/httpapi-exercise/environment") await import("../test/server/httpapi-exercise/index") diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 6c02288bcd..4e0a112177 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -20,7 +20,7 @@ import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields, } from "../middleware/workspace-routing" -import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../errors" +import { ApiNotFoundError, ConflictError, PermissionNotFoundError, SessionBusyError } from "../errors" import { described } from "./metadata" import { QueryBoolean } from "./query" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -40,6 +40,10 @@ export const DiffQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), }) +export const AssessedDiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + patch: Schema.optional(QueryBoolean), +}) export const MessagesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), @@ -80,6 +84,54 @@ export const TouchedFile = Schema.Struct({ status: Schema.Literals(["added", "modified"]), }) +export const AssessedExternalDiff = Schema.Struct({ + reference: Schema.String, + file: Schema.String, + state: Schema.Literals(["changed", "unchanged", "unavailable"]), + status: Schema.optional(Schema.Literals(["added", "modified", "deleted"])), + patch: Schema.optional(Schema.String), + additions: Schema.optional(Schema.Number), + deletions: Schema.optional(Schema.Number), +}) + +export const AssessedExternalDiffResponse = Schema.Struct({ + version: Schema.Literal(1), + revision: Schema.Number, + assessments: Schema.Array(AssessedExternalDiff), +}) + +/** Versioned extension transport. Prepare accepts paths; later calls carry only opaque server-issued values. */ +export const ExternalReservationEndpoint = Schema.Struct({ + reference: Schema.String, + capability: Schema.String, + revision: Schema.Number, +}) +export const ExternalReservation = Schema.Struct({ + id: Schema.String, + expiresAt: Schema.Number, + endpoints: Schema.Array(ExternalReservationEndpoint), +}) +export const ExternalReservationPreparePayload = Schema.Struct({ + version: Schema.Literal(1), + files: Schema.Array(Schema.String), +}) +export const ExternalReservationPayload = Schema.Struct({ + version: Schema.Literal(1), + reservation: ExternalReservation, +}) +export const ExternalReservationPrepareResponse = Schema.Struct({ + version: Schema.Literal(1), + reservation: ExternalReservation, +}) +export const ExternalReservationCommitResponse = Schema.Struct({ + version: Schema.Literal(1), + committed: Schema.Literal(true), +}) +export const ExternalReservationAbortResponse = Schema.Struct({ + version: Schema.Literal(1), + aborted: Schema.Literal(true), +}) + export const SessionPaths = { list: root, status: `${root}/status`, @@ -87,6 +139,10 @@ export const SessionPaths = { children: `${root}/:sessionID/children`, todo: `${root}/:sessionID/todo`, diff: `${root}/:sessionID/diff`, + assessedDiff: `${root}/:sessionID/diff/assessed`, + externalReservationPrepare: `${root}/:sessionID/external-diff/reservations/prepare`, + externalReservationCommit: `${root}/:sessionID/external-diff/reservations/commit`, + externalReservationAbort: `${root}/:sessionID/external-diff/reservations/abort`, touchedFiles: `${root}/:sessionID/touched-files`, messages: `${root}/:sessionID/message`, message: `${root}/:sessionID/message/:messageID`, @@ -182,6 +238,57 @@ export const SessionApi = HttpApi.make("session") description: "Get the file changes (diff) that resulted from a specific user message in the session.", }), ), + HttpApiEndpoint.get("assessedDiff", SessionPaths.assessedDiff, { + params: { sessionID: SessionID }, + query: AssessedDiffQuery, + success: described(AssessedExternalDiffResponse, "Server-assessed external file diffs"), + error: ApiNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.assessedDiff", + summary: "Get server-assessed external file diffs", + description: "Get current session-owned external file assessments.", + }), + ), + HttpApiEndpoint.post("externalReservationPrepare", SessionPaths.externalReservationPrepare, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + payload: ExternalReservationPreparePayload, + success: described(ExternalReservationPrepareResponse, "Prepared opaque external mutation reservation"), + error: [HttpApiError.BadRequest, ApiNotFoundError, ConflictError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.externalReservationPrepare", + summary: "Prepare an external mutation reservation", + description: "Capture server-owned external baselines and return opaque reservation capabilities.", + }), + ), + HttpApiEndpoint.post("externalReservationCommit", SessionPaths.externalReservationCommit, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + payload: ExternalReservationPayload, + success: described(ExternalReservationCommitResponse, "Committed external mutation reservation"), + error: [HttpApiError.BadRequest, ApiNotFoundError, ConflictError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.externalReservationCommit", + summary: "Commit an external mutation reservation", + description: "Atomically record server-verified endpoint state for an opaque reservation group.", + }), + ), + HttpApiEndpoint.post("externalReservationAbort", SessionPaths.externalReservationAbort, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + payload: ExternalReservationPayload, + success: described(ExternalReservationAbortResponse, "Aborted external mutation reservation"), + error: [HttpApiError.BadRequest, ApiNotFoundError, ConflictError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.externalReservationAbort", + summary: "Abort an external mutation reservation", + description: "Conditionally discard a prepared opaque reservation without replacing committed state.", + }), + ), HttpApiEndpoint.get("touchedFiles", SessionPaths.touchedFiles, { params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, @@ -191,7 +298,8 @@ export const SessionApi = HttpApi.make("session") OpenApi.annotations({ identifier: "session.touchedFiles", summary: "Get touched files", - description: "Get all files that were edited or written by tools in this session, regardless of current git state.", + description: + "Get all files that were edited or written by tools in this session, regardless of current git state.", }), ), HttpApiEndpoint.get("messages", SessionPaths.messages, { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 0fc56ba55d..c6aa34c833 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -6,6 +6,7 @@ import { Command } from "@/command" import { Permission } from "@/permission" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" +import { ExternalDiff } from "@/session/external-diff" import { SessionCompaction } from "@/session/compaction" import { MessageV2 } from "@/session/message-v2" import { SessionPrompt } from "@/session/prompt" @@ -19,12 +20,14 @@ import { NamedError } from "@opencode-ai/core/util/error" import { Cause, Effect, Option, Schema, Scope } from "effect" import * as Stream from "effect/Stream" import { InstanceState } from "@/effect/instance-state" -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { CommandPayload, DiffQuery, + ExternalReservationPayload, + ExternalReservationPreparePayload, ForkPayload, InitPayload, ListQuery, @@ -36,7 +39,7 @@ import { SummarizePayload, UpdatePayload, } from "../groups/session" -import { PermissionNotFoundError } from "../errors" +import { ConflictError, PermissionNotFoundError } from "../errors" import * as SessionError from "./session-errors" const tryParseJson = (text: string) => @@ -103,15 +106,62 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", return yield* session.diff(ctx.params.sessionID) }) + const assessedDiff = Effect.fn("SessionHttpApi.assessedDiff")(function* (ctx: { + params: { sessionID: SessionID } + query: { patch?: boolean } + }) { + yield* requireSession(ctx.params.sessionID) + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, "cache-control", "no-store")), + ) + return ExternalDiff.assessed(ctx.params.sessionID, { patch: ctx.query.patch === true }) + }) + + const noStore = () => + HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, "cache-control", "no-store")), + ) + + const externalReservationPrepare = Effect.fn("SessionHttpApi.externalReservationPrepare")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof ExternalReservationPreparePayload.Type + }) { + yield* requireSession(ctx.params.sessionID) + yield* noStore() + const reservation = ExternalDiff.prepareTransport({ sessionID: ctx.params.sessionID, files: ctx.payload.files }) + if (!reservation) return yield* new HttpApiError.BadRequest({}) + return { version: 1 as const, reservation } + }) + + const externalReservationCommit = Effect.fn("SessionHttpApi.externalReservationCommit")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof ExternalReservationPayload.Type + }) { + yield* requireSession(ctx.params.sessionID) + yield* noStore() + if (!ExternalDiff.commitTransport({ sessionID: ctx.params.sessionID, reservation: ctx.payload.reservation })) + return yield* new ConflictError({ message: "Reservation is no longer current" }) + return { version: 1 as const, committed: true as const } + }) + + const externalReservationAbort = Effect.fn("SessionHttpApi.externalReservationAbort")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof ExternalReservationPayload.Type + }) { + yield* requireSession(ctx.params.sessionID) + yield* noStore() + if (!ExternalDiff.abortTransport({ sessionID: ctx.params.sessionID, reservation: ctx.payload.reservation })) + return yield* new ConflictError({ message: "Reservation is no longer current" }) + return { version: 1 as const, aborted: true as const } + }) + const EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch"]) const touchedFiles = Effect.fn("SessionHttpApi.touchedFiles")(function* (ctx: { params: { sessionID: SessionID } }) { yield* requireSession(ctx.params.sessionID) - const allMessages = yield* SessionError.mapStorageNotFound( - session.messages({ sessionID: ctx.params.sessionID }), - ) + const allMessages = yield* SessionError.mapStorageNotFound(session.messages({ sessionID: ctx.params.sessionID })) const seen = new Map() for (const msg of allMessages) { for (const part of msg.parts) { @@ -441,6 +491,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", .handle("children", children) .handle("todo", todo) .handle("diff", diff) + .handle("assessedDiff", assessedDiff) + .handle("externalReservationPrepare", externalReservationPrepare) + .handle("externalReservationCommit", externalReservationCommit) + .handle("externalReservationAbort", externalReservationAbort) .handle("touchedFiles", touchedFiles) .handle("messages", messages) .handle("message", message) diff --git a/packages/opencode/src/session/external-diff.ts b/packages/opencode/src/session/external-diff.ts new file mode 100644 index 0000000000..aa3dd0c0a2 --- /dev/null +++ b/packages/opencode/src/session/external-diff.ts @@ -0,0 +1,456 @@ +import { createHash } from "node:crypto" +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs" +import * as path from "path" +import { Global } from "@opencode-ai/core/global" +import { createTwoFilesPatch, diffLines } from "diff" + +/** + * Host-local, deliberately non-session storage for external preimages. The + * manifest contains only digests and artifact names; generated patches never + * cross this boundary and are reconstructed for an opted-in detail response. + */ +export namespace ExternalDiff { + export type Assessment = + | { + reference: string + file: string + state: "changed" + status: "added" | "modified" | "deleted" + patch?: string + additions: number + deletions: number + } + | { reference: string; file: string; state: "unchanged" | "unavailable" } + + type Endpoint = { present: true; content: string } | { present: false } + type Expected = { present: boolean; digest?: string } + type Entry = { + reference: string + file: string + baseline: Endpoint + expected?: Expected + revision: number + unavailable?: boolean + assessment?: Assessment + } + type PersistedEntry = Omit & { + baseline: { present: boolean; artifact?: string } + } + type Manifest = { + version: 1 + generation: number + entries: PersistedEntry[] + leases?: string[] + } + type ReservedEndpoint = { + reference: string + file: string + revision: number + capability: string + created: boolean + } + type Reservation = { + id: string + sessionID: string + expiresAt: number + endpoints: ReservedEndpoint[] + state: "prepared" | "committed" + } + export type PreparedReservation = Pick + export type TransportReservation = { + id: string + expiresAt: number + endpoints: ReadonlyArray> + } + + const entries = new Map>() + const revisions = new Map() + const reservations = new Map() + const hydrated = new Set() + const root = () => path.join(Global.Path.data, "external-diff") + const sessionDir = (sessionID: string) => path.join(root(), encodeURIComponent(sessionID)) + const manifestFile = (sessionID: string) => path.join(sessionDir(sessionID), "manifest.json") + const artifactFile = (sessionID: string, reference: string) => + path.join(sessionDir(sessionID), "artifacts", reference) + const tombstoneFile = (sessionID: string) => path.join(root(), "tombstones", encodeURIComponent(sessionID)) + + function canonical(file: string) { + return path.resolve(file) + } + function digest(endpoint: Endpoint): Expected { + return endpoint.present + ? { present: true, digest: createHash("sha256").update(endpoint.content).digest("hex") } + : { present: false } + } + function expectedMatches(expected: Expected, current: Endpoint) { + const actual = digest(current) + return expected.present === actual.present && expected.digest === actual.digest + } + function read(file: string): Endpoint | undefined { + try { + if (!existsSync(file)) return { present: false } + return { present: true, content: readFileSync(file, "utf8") } + } catch { + return undefined + } + } + function same(left: Endpoint, right: Endpoint) { + return left.present === right.present && (!left.present || left.content === (right as { content: string }).content) + } + function readManifest(sessionID: string): Manifest | undefined { + try { + const parsed = JSON.parse(readFileSync(manifestFile(sessionID), "utf8")) + if (parsed?.version !== 1 || !Array.isArray(parsed.entries)) return + return parsed as Manifest + } catch { + return + } + } + function tombstoned(sessionID: string) { + return existsSync(tombstoneFile(sessionID)) + } + function persist(sessionID: string, session: Map, leases?: string[]) { + if (tombstoned(sessionID)) return false + mkdirSync(sessionDir(sessionID), { recursive: true }) + const previous = readManifest(sessionID) + const manifest: Manifest = { + version: 1, + generation: (previous?.generation ?? 0) + 1, + leases: leases?.length ? leases : undefined, + entries: [...session.values()].map((entry) => ({ + reference: entry.reference, + file: entry.file, + expected: entry.expected, + revision: entry.revision, + unavailable: entry.unavailable, + baseline: entry.baseline.present ? { present: true, artifact: entry.reference } : { present: false }, + })), + } + const target = manifestFile(sessionID) + const temporary = `${target}.${crypto.randomUUID()}.tmp` + writeFileSync(temporary, JSON.stringify(manifest)) + if (tombstoned(sessionID)) { + rmSync(temporary, { force: true }) + return false + } + renameSync(temporary, target) + return true + } + function writeArtifact(sessionID: string, entry: Entry) { + if (!entry.baseline.present) return true + if (tombstoned(sessionID)) return false + const target = artifactFile(sessionID, entry.reference) + mkdirSync(path.dirname(target), { recursive: true }) + const temporary = `${target}.${crypto.randomUUID()}.tmp` + writeFileSync(temporary, entry.baseline.content) + if (tombstoned(sessionID)) { + rmSync(temporary, { force: true }) + return false + } + renameSync(temporary, target) + if (!tombstoned(sessionID)) return true + rmSync(target, { force: true }) + return false + } + function hydrate(sessionID: string) { + // Startup and lazy rehydration repair interrupted artifact writes without + // touching a directory protected by a manifest lease. + sweep() + if (hydrated.has(sessionID)) return + hydrated.add(sessionID) + if (tombstoned(sessionID)) return + const manifest = readManifest(sessionID) + if (!manifest) return + const session = new Map() + for (const stored of manifest.entries) { + let baseline: Endpoint = { present: false } + let unavailable = stored.unavailable + if (stored.baseline.present) { + const artifact = stored.baseline.artifact && artifactFile(sessionID, stored.baseline.artifact) + try { + if (!artifact) throw new Error("missing artifact name") + baseline = { present: true, content: readFileSync(artifact, "utf8") } + } catch { + unavailable = true + } + } + session.set(stored.file, { + reference: stored.reference, + file: stored.file, + baseline, + expected: stored.expected, + revision: stored.revision, + unavailable, + }) + } + if (session.size) entries.set(sessionID, session) + } + function changed(entry: Entry, current: Endpoint, patch: boolean): Assessment { + const baseline = entry.baseline.present ? entry.baseline.content : "" + const content = current.present ? current.content : "" + let additions = 0 + let deletions = 0 + for (const change of diffLines(baseline, content)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + return { + reference: entry.reference, + file: entry.file, + state: "changed", + status: !entry.baseline.present ? "added" : !current.present ? "deleted" : "modified", + ...(patch ? { patch: createTwoFilesPatch(entry.file, entry.file, baseline, content) } : {}), + additions, + deletions, + } + } + function setAssessment(sessionID: string, entry: Entry, assessment: Assessment) { + if (JSON.stringify(entry.assessment) === JSON.stringify(assessment)) return + entry.assessment = assessment + revisions.set(sessionID, (revisions.get(sessionID) ?? 0) + 1) + } + function reassess(sessionID: string, entry: Entry) { + const current = read(entry.file) + if (!current || entry.unavailable) + return setAssessment(sessionID, entry, { reference: entry.reference, file: entry.file, state: "unavailable" }) + if (same(entry.baseline, current)) + return setAssessment(sessionID, entry, { reference: entry.reference, file: entry.file, state: "unchanged" }) + if (entry.expected && !expectedMatches(entry.expected, current)) + return setAssessment(sessionID, entry, { reference: entry.reference, file: entry.file, state: "unavailable" }) + return setAssessment(sessionID, entry, changed(entry, current, false)) + } + + /** Reserve all endpoints before a registered mutation. Duplicate canonical paths reject self-moves. */ + export function prepare(input: { + sessionID: string + files: readonly string[] + ttlMs?: number + }): PreparedReservation | undefined { + if (tombstoned(input.sessionID)) return + hydrate(input.sessionID) + const files = input.files.map(canonical) + if (files.length === 0 || new Set(files).size !== files.length) return + const current = entries.get(input.sessionID) + const baselines = new Map() + for (const file of files) { + if (current?.has(file)) continue + const baseline = read(file) + if (!baseline) return + baselines.set(file, baseline) + } + const session = current ?? new Map() + if (!current) entries.set(input.sessionID, session) + const created: Entry[] = [] + const endpoints: ReservedEndpoint[] = [] + for (const file of files) { + const existing = session.get(file) + if (existing) { + endpoints.push({ + reference: existing.reference, + file, + revision: existing.revision, + capability: crypto.randomUUID(), + created: false, + }) + continue + } + const entry = { + reference: `external_${crypto.randomUUID()}`, + file, + baseline: baselines.get(file)!, + revision: 0, + } satisfies Entry + session.set(file, entry) + created.push(entry) + setAssessment(input.sessionID, entry, { reference: entry.reference, file, state: "unavailable" }) + endpoints.push({ reference: entry.reference, file, revision: 0, capability: crypto.randomUUID(), created: true }) + } + // Publish a lease before artifact bytes. A tombstone wins over this lease, + // and a late creator fails closed instead of recreating deleted ownership. + if ( + !persist( + input.sessionID, + session, + created.map((entry) => entry.reference), + ) || + !created.every((entry) => writeArtifact(input.sessionID, entry)) || + !persist(input.sessionID, session) + ) { + for (const entry of created) session.delete(entry.file) + if (session.size === 0) entries.delete(input.sessionID) + return + } + const reservation: Reservation = { + id: crypto.randomUUID(), + sessionID: input.sessionID, + expiresAt: Date.now() + (input.ttlMs ?? 60_000), + endpoints, + state: "prepared", + } + reservations.set(reservation.id, reservation) + return reservation + } + function valid(input: { sessionID: string; reservation: PreparedReservation }) { + if (tombstoned(input.sessionID)) return + const stored = reservations.get(input.reservation.id) + if (!stored || stored.sessionID !== input.sessionID || stored.sessionID !== input.reservation.sessionID) return + if (stored.expiresAt !== input.reservation.expiresAt) return + if ( + stored.endpoints.length !== input.reservation.endpoints.length || + stored.endpoints.some((endpoint, index) => { + const candidate = input.reservation.endpoints[index] + return ( + endpoint.reference !== candidate?.reference || + endpoint.capability !== candidate?.capability || + endpoint.revision !== candidate?.revision || + endpoint.file !== candidate?.file + ) + }) + ) + return + // A completed group is a durable idempotency record for its short lease. + // Its endpoint revisions have moved, so return it before the prepared-only + // revision and expiry checks below. + if (stored.state === "committed") return stored + if (stored.expiresAt < Date.now()) return + const session = entries.get(input.sessionID) + if (!session || stored.endpoints.some((endpoint) => session.get(endpoint.file)?.revision !== endpoint.revision)) + return + return stored + } + function transport(reservation: PreparedReservation): TransportReservation { + return { + id: reservation.id, + expiresAt: reservation.expiresAt, + endpoints: reservation.endpoints.map(({ reference, revision, capability }) => ({ + reference, + revision, + capability, + })), + } + } + function validTransport(input: { sessionID: string; reservation: TransportReservation }) { + const stored = reservations.get(input.reservation.id) + if (tombstoned(input.sessionID) || !stored || stored.sessionID !== input.sessionID) return + if ( + stored.expiresAt !== input.reservation.expiresAt || + stored.endpoints.length !== input.reservation.endpoints.length + ) + return + if ( + stored.endpoints.some((endpoint, index) => { + const candidate = input.reservation.endpoints[index] + return ( + endpoint.reference !== candidate?.reference || + endpoint.capability !== candidate?.capability || + endpoint.revision !== candidate?.revision + ) + }) + ) + return + if (stored.state === "committed") return stored + if (stored.expiresAt < Date.now()) return + const session = entries.get(input.sessionID) + if (!session || stored.endpoints.some((endpoint) => session.get(endpoint.file)?.revision !== endpoint.revision)) + return + return stored + } + /** HTTP-safe reservation handoff: paths and baseline bytes never leave this module. */ + export function prepareTransport(input: { sessionID: string; files: readonly string[] }) { + const reservation = prepare(input) + return reservation && transport(reservation) + } + /** Commit a complete reservation group from endpoint bytes read by this server. */ + export function commit(input: { sessionID: string; reservation: PreparedReservation }) { + const reservation = valid(input) + if (!reservation) return false + if (reservation.state === "committed") return true + const session = entries.get(input.sessionID)! + const states = reservation.endpoints.map((endpoint) => read(endpoint.file)) + if (states.some((state) => !state)) return false + for (const [index, endpoint] of reservation.endpoints.entries()) { + const entry = session.get(endpoint.file)! + entry.expected = digest(states[index]!) + entry.revision++ + reassess(input.sessionID, entry) + } + if (!persist(input.sessionID, session)) return false + reservation.state = "committed" + return true + } + /** Commit an opaque HTTP reservation group without accepting caller-supplied paths or endpoint state. */ + export function commitTransport(input: { sessionID: string; reservation: TransportReservation }) { + const reservation = validTransport(input) + if (!reservation) return false + return commit({ sessionID: input.sessionID, reservation }) + } + /** Abort a reservation without replacing a prior committed expected state. */ + export function abort(input: { sessionID: string; reservation: PreparedReservation }) { + const reservation = valid(input) + if (!reservation || reservation.state !== "prepared") return false + const session = entries.get(input.sessionID)! + for (const endpoint of reservation.endpoints) { + const entry = session.get(endpoint.file)! + if (endpoint.created && !entry.expected) { + session.delete(endpoint.file) + rmSync(artifactFile(input.sessionID, endpoint.reference), { force: true }) + continue + } + reassess(input.sessionID, entry) + } + if (session.size === 0) entries.delete(input.sessionID) + else persist(input.sessionID, session) + reservations.delete(reservation.id) + return true + } + /** Abort remains conditional: completed groups retain their committed expected state. */ + export function abortTransport(input: { sessionID: string; reservation: TransportReservation }) { + const reservation = validTransport(input) + if (!reservation || reservation.state !== "prepared") return false + return abort({ sessionID: input.sessionID, reservation }) + } + /** Delete ownership before physical artifacts so a stale reservation can never republish it. */ + export function remove(sessionID: string) { + mkdirSync(path.dirname(tombstoneFile(sessionID)), { recursive: true }) + writeFileSync(tombstoneFile(sessionID), JSON.stringify({ generation: Date.now() })) + entries.delete(sessionID) + hydrated.add(sessionID) + for (const [id, reservation] of reservations) if (reservation.sessionID === sessionID) reservations.delete(id) + rmSync(sessionDir(sessionID), { recursive: true, force: true }) + } + /** Idempotently removes only unmanifested or tombstoned artifact directories. */ + export function sweep() { + for (const [id, reservation] of reservations) if (reservation.expiresAt < Date.now()) reservations.delete(id) + try { + for (const name of readdirSync(root())) { + if (name === "tombstones") continue + const id = decodeURIComponent(name) + if (tombstoned(id) || !readManifest(id)) rmSync(path.join(root(), name), { recursive: true, force: true }) + } + } catch {} + } + export function assessed( + sessionID: string, + options?: { patch?: boolean }, + ): { version: 1; revision: number; assessments: Assessment[] } { + hydrate(sessionID) + const session = entries.get(sessionID) + if (session) for (const entry of session.values()) if (entry.expected) reassess(sessionID, entry) + const assessments = session + ? [...session.values()].flatMap((entry) => { + if (!entry.assessment) return [] + if (!options?.patch || entry.assessment.state !== "changed") return [entry.assessment] + const current = read(entry.file) + return current ? [changed(entry, current, true)] : [entry.assessment] + }) + : [] + return { version: 1, revision: revisions.get(sessionID) ?? 0, assessments } + } + /** Test-only restart seam; production restart rehydrates lazily from the manifest. */ + export function resetMemoryForTest() { + entries.clear() + revisions.clear() + reservations.clear() + hydrated.clear() + } +} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a74a514661..27b275c7fd 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -32,6 +32,7 @@ import { MessageV2 } from "./message-v2" import type { InstanceContext } from "../project/instance-context" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" +import { ExternalDiff } from "@/session/external-diff" import { ProjectV2 } from "@opencode-ai/core/project" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { SessionID, MessageID, PartID } from "./schema" @@ -623,6 +624,10 @@ const layer: Layer.Layer< yield* remove(child.id) } + // External baselines are host-local and never belong to a fork or a + // transcript. Tombstone them before deleting session records so an + // in-flight mutation cannot recreate ownership after teardown starts. + ExternalDiff.remove(sessionID) yield* events.publish(SessionV1.Event.Deleted, { sessionID, info: session }) yield* events.remove(sessionID) } catch (error) { diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index f9201be8a7..3f4fad60e2 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -14,6 +14,7 @@ import DESCRIPTION from "./apply_patch.txt" import { FileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../format" import * as Bom from "@/util/bom" +import { ExternalDiff } from "@/session/external-diff" export const Parameters = Schema.Struct({ patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }), @@ -65,13 +66,15 @@ export const ApplyPatchTool = Tool.define( additions: number deletions: number bom: boolean + external: boolean + moveExternal: boolean }> = [] let totalDiff = "" for (const hunk of hunks) { const filePath = path.resolve(instance.directory, hunk.path) - yield* assertExternalDirectoryEffect(ctx, filePath) + const external = yield* assertExternalDirectoryEffect(ctx, filePath) switch (hunk.type) { case "add": { @@ -97,6 +100,8 @@ export const ApplyPatchTool = Tool.define( additions, deletions, bom: next.bom, + external, + moveExternal: false, }) totalDiff += diff + "\n" @@ -140,7 +145,7 @@ export const ApplyPatchTool = Tool.define( } const movePath = hunk.move_path ? path.resolve(instance.directory, hunk.move_path) : undefined - yield* assertExternalDirectoryEffect(ctx, movePath) + const moveExternal = yield* assertExternalDirectoryEffect(ctx, movePath) fileChanges.push({ filePath, @@ -152,6 +157,8 @@ export const ApplyPatchTool = Tool.define( additions, deletions, bom, + external, + moveExternal, }) totalDiff += diff + "\n" @@ -182,6 +189,8 @@ export const ApplyPatchTool = Tool.define( additions: 0, deletions, bom: source.bom, + external, + moveExternal: false, }) totalDiff += deleteDiff + "\n" @@ -214,48 +223,69 @@ export const ApplyPatchTool = Tool.define( }, }) + const externalFiles = [ + ...new Set( + fileChanges.flatMap((change) => [ + ...(change.external ? [change.filePath] : []), + ...(change.movePath && change.moveExternal ? [change.movePath] : []), + ]), + ), + ] + const reservation = externalFiles.length + ? ExternalDiff.prepare({ sessionID: ctx.sessionID, files: externalFiles }) + : undefined + // Apply the changes const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] - for (const change of fileChanges) { - const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) - switch (change.type) { - case "add": - // Create parent directories (recursive: true is safe on existing/root dirs) + yield* Effect.gen(function* () { + for (const change of fileChanges) { + const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) + switch (change.type) { + case "add": + // Create parent directories (recursive: true is safe on existing/root dirs) - yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom)) - updates.push({ file: change.filePath, event: "add" }) - break + yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom)) + updates.push({ file: change.filePath, event: "add" }) + break - case "update": - yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom)) - updates.push({ file: change.filePath, event: "change" }) - break + case "update": + yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom)) + updates.push({ file: change.filePath, event: "change" }) + break - case "move": - if (change.movePath) { - // Create parent directories (recursive: true is safe on existing/root dirs) + case "move": + if (change.movePath) { + // Create parent directories (recursive: true is safe on existing/root dirs) - yield* afs.writeWithDirs(change.movePath!, Bom.join(change.newContent, change.bom)) + yield* afs.writeWithDirs(change.movePath, Bom.join(change.newContent, change.bom)) + yield* afs.remove(change.filePath) + updates.push({ file: change.filePath, event: "unlink" }) + updates.push({ file: change.movePath, event: "add" }) + } + break + + case "delete": yield* afs.remove(change.filePath) updates.push({ file: change.filePath, event: "unlink" }) - updates.push({ file: change.movePath, event: "add" }) - } - break - - case "delete": - yield* afs.remove(change.filePath) - updates.push({ file: change.filePath, event: "unlink" }) - break - } + break + } - if (edited) { - if (yield* format.file(edited)) { - yield* Bom.syncFile(afs, edited, change.bom) + if (edited) { + if (yield* format.file(edited)) { + yield* Bom.syncFile(afs, edited, change.bom) + } + yield* events.publish(FileSystem.Event.Edited, { file: edited }) } - yield* events.publish(FileSystem.Event.Edited, { file: edited }) } - } + if (reservation) ExternalDiff.commit({ sessionID: ctx.sessionID, reservation }) + }).pipe( + Effect.onError(() => + Effect.sync(() => { + if (reservation) ExternalDiff.abort({ sessionID: ctx.sessionID, reservation }) + }), + ), + ) // Publish file change events for (const update of updates) { diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a92e4720c0..1002eea70d 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -15,6 +15,7 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { Format } from "../format" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" +import { ExternalDiff } from "@/session/external-diff" import { assertExternalDirectoryEffect } from "./external-directory" import { FSUtil } from "@opencode-ai/core/fs-util" import * as Bom from "@/util/bom" @@ -80,7 +81,8 @@ export const EditTool = Tool.define( const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(instance.directory, params.filePath) - yield* assertExternalDirectoryEffect(ctx, filePath) + const external = yield* assertExternalDirectoryEffect(ctx, filePath) + let reservation: ExternalDiff.PreparedReservation | undefined let diff = "" let contentOld = "" @@ -108,10 +110,14 @@ export const EditTool = Tool.define( diff, }, }) + if (external) { + reservation = ExternalDiff.prepare({ sessionID: ctx.sessionID, files: [filePath] }) + } yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom)) if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } + if (reservation) ExternalDiff.commit({ sessionID: ctx.sessionID, reservation }) yield* events.publish(FileSystem.Event.Edited, { file: filePath }) yield* events.publish(Watcher.Event.Updated, { file: filePath, @@ -151,11 +157,15 @@ export const EditTool = Tool.define( diff, }, }) + if (external) { + reservation = ExternalDiff.prepare({ sessionID: ctx.sessionID, files: [filePath] }) + } yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom)) if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } + if (reservation) ExternalDiff.commit({ sessionID: ctx.sessionID, reservation }) yield* events.publish(FileSystem.Event.Edited, { file: filePath }) yield* events.publish(Watcher.Event.Updated, { file: filePath, @@ -169,7 +179,14 @@ export const EditTool = Tool.define( normalizeLineEndings(contentNew), ), ) - }).pipe(Effect.orDie), + }).pipe( + Effect.onError(() => + Effect.sync(() => { + if (reservation) ExternalDiff.abort({ sessionID: ctx.sessionID, reservation }) + }), + ), + Effect.orDie, + ), ) let additions = 0 diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 58d23e0f4f..18899332f4 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -12,6 +12,7 @@ import { Format } from "../format" import { FSUtil } from "@opencode-ai/core/fs-util" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" +import { ExternalDiff } from "@/session/external-diff" import { trimDiff } from "./edit" import { assertExternalDirectoryEffect } from "./external-directory" import * as Bom from "@/util/bom" @@ -42,7 +43,7 @@ export const WriteTool = Tool.define( const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(instance.directory, params.filePath) - yield* assertExternalDirectoryEffect(ctx, filepath) + const external = yield* assertExternalDirectoryEffect(ctx, filepath) const exists = yield* fs.existsSafe(filepath) const source = exists ? yield* Bom.readFile(fs, filepath) : { bom: false, text: "" } @@ -61,6 +62,9 @@ export const WriteTool = Tool.define( diff, }, }) + const reservation = external + ? ExternalDiff.prepare({ sessionID: ctx.sessionID, files: [filepath] }) + : undefined // Compute filediff for the Files Changed panel (same pattern as edit.ts) let additions = 0 @@ -85,10 +89,20 @@ export const WriteTool = Tool.define( }, }) - yield* fs.writeWithDirs(filepath, Bom.join(contentNew, desiredBom)) - if (yield* format.file(filepath)) { - yield* Bom.syncFile(fs, filepath, desiredBom) - } + yield* Effect.gen(function* () { + yield* fs.writeWithDirs(filepath, Bom.join(contentNew, desiredBom)) + let settled = contentNew + if (yield* format.file(filepath)) { + settled = yield* Bom.syncFile(fs, filepath, desiredBom) + } + if (reservation) ExternalDiff.commit({ sessionID: ctx.sessionID, reservation }) + }).pipe( + Effect.onError(() => + Effect.sync(() => { + if (reservation) ExternalDiff.abort({ sessionID: ctx.sessionID, reservation }) + }), + ), + ) yield* events.publish(FileSystem.Event.Edited, { file: filepath }) yield* events.publish(Watcher.Event.Updated, { file: filepath, diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index a2cada6649..74f7c601b6 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -21,6 +21,7 @@ import { Effect } from "effect" import { OpenApi } from "effect/unstable/httpapi" import { TestLLMServer } from "../../lib/llm-server" import path from "path" +import { ExternalDiff } from "../../../src/session/external-diff" import { array, boolean, check, isRecord, message, object, stable } from "./assertions" import { controlledPtyInput, http, route } from "./dsl" import { @@ -1288,6 +1289,88 @@ const scenarios: Scenario[] = [ .seeded((ctx) => ctx.session({ title: "Diff session" })) .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) .json(200, array), + http.protected + .get("/session/{sessionID}/diff/assessed", "session.assessedDiff") + .seeded((ctx) => ctx.session({ title: "Assessed external diff session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/diff/assessed", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json(200, (body) => { + object(body) + check(body.version === 1, "assessed diff response should be versioned") + check(body.revision === 0, "new session should have no external assessment revision") + array(body.assessments) + check(body.assessments.length === 0, "new session should have no external assessments") + }), + http.protected + .post("/session/{sessionID}/external-diff/reservations/prepare", "session.externalReservationPrepare") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "External reservation prepare" }) + const file = path.join(ctx.directory!, "external-reservation-prepare.txt") + yield* ctx.file("external-reservation-prepare.txt", "before\n") + return { session, file } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/external-diff/reservations/prepare", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { version: 1, files: [ctx.state.file] }, + })) + .json(200, (body, ctx) => { + object(body) + check(body.version === 1, "prepare should return the transport version") + object(body.reservation) + check(typeof body.reservation.id === "string", "prepare should return a reservation ID") + check(typeof body.reservation.expiresAt === "number", "prepare should return a reservation expiry") + array(body.reservation.endpoints) + check(body.reservation.endpoints.length === 1, "prepare should reserve the requested file") + check(!JSON.stringify(body).includes(ctx.state.file), "prepare should not expose the caller file path") + }), + http.protected + .post("/session/{sessionID}/external-diff/reservations/commit", "session.externalReservationCommit") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "External reservation commit" }) + const file = path.join(ctx.directory!, "external-reservation-commit.txt") + yield* ctx.file("external-reservation-commit.txt", "before\n") + const reservation = ExternalDiff.prepareTransport({ sessionID: session.id, files: [file] }) + check(reservation !== undefined, "commit scenario should prepare an opaque reservation") + yield* ctx.file("external-reservation-commit.txt", "after\n") + return { session, reservation } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/external-diff/reservations/commit", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { version: 1, reservation: ctx.state.reservation }, + })) + .json(200, (body) => { + object(body) + check(body.version === 1 && body.committed === true, "commit should confirm the prepared reservation") + }), + http.protected + .post("/session/{sessionID}/external-diff/reservations/abort", "session.externalReservationAbort") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "External reservation abort" }) + const file = path.join(ctx.directory!, "external-reservation-abort.txt") + yield* ctx.file("external-reservation-abort.txt", "before\n") + const reservation = ExternalDiff.prepareTransport({ sessionID: session.id, files: [file] }) + check(reservation !== undefined, "abort scenario should prepare an opaque reservation") + return { session, reservation } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/external-diff/reservations/abort", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { version: 1, reservation: ctx.state.reservation }, + })) + .json(200, (body) => { + object(body) + check(body.version === 1 && body.aborted === true, "abort should conditionally discard a prepared reservation") + }), http.protected .get("/session/{sessionID}/touched-files", "session.touchedFiles") .seeded((ctx) => ctx.session({ title: "Touched files session" })) diff --git a/packages/opencode/test/server/httpapi-instance-route-auth.test.ts b/packages/opencode/test/server/httpapi-instance-route-auth.test.ts index 9933a28bdc..9cbbc14cff 100644 --- a/packages/opencode/test/server/httpapi-instance-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-instance-route-auth.test.ts @@ -3,9 +3,11 @@ import { ConfigProvider, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { ServerAuth } from "../../src/server/auth" import { PtyID } from "@opencode-ai/core/pty/schema" +import { SessionID } from "../../src/session/schema" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -78,4 +80,22 @@ describe("HttpApi instance route authorization", () => { await cancelBody(authed) expect(authed.status).toBe(404) }) + + test("requires configured auth before preparing an external mutation reservation", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const server = app({ password: "secret" }) + const route = SessionPaths.externalReservationPrepare.replace(":sessionID", SessionID.make("ses_auth_probe")) + const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } + const body = JSON.stringify({ version: 1, files: ["/tmp/external.txt"] }) + + const missing = await server.request(route, { method: "POST", headers, body }) + expect(missing.status).toBe(401) + + const authed = await server.request(route, { + method: "POST", + headers: { ...headers, authorization: basic("opencode", "secret") }, + body, + }) + expect(authed.status).toBe(404) + }) }) diff --git a/packages/opencode/test/server/session-diff-scoped.test.ts b/packages/opencode/test/server/session-diff-scoped.test.ts index 6cede5a6e5..a50bedd267 100644 --- a/packages/opencode/test/server/session-diff-scoped.test.ts +++ b/packages/opencode/test/server/session-diff-scoped.test.ts @@ -15,6 +15,7 @@ import path from "path" import { Effect, Layer } from "effect" import { Session } from "@/session/session" import { Snapshot } from "@/snapshot" +import { ExternalDiff } from "@/session/external-diff" import { Storage } from "@/storage/storage" import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" import { MessageID, PartID } from "@/session/schema" @@ -46,6 +47,524 @@ const withSession = (input?: Parameters[0]) => Effect.acquireRelease(Session.use.create(input), (created) => Session.use.remove(created.id).pipe(Effect.ignore)) describe("Session.diff — session-scoped agent diffs (#174)", () => { + it.instance( + "prepares an opaque external reservation over the authenticated session API", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "external-http-reservation" }) + const fs = yield* FSUtil.Service + const sibling = path.join(path.dirname(test.directory), `external-http-${session.id}.txt`) + yield* fs.writeWithDirs(sibling, "before\n") + + const response = yield* requestInDirectory( + `/session/${session.id}/external-diff/reservations/prepare`, + test.directory, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ version: 1, files: [sibling] }), + }, + ) + + expect(response.status).toBe(200) + const body = yield* response.json + expect(body).toEqual({ + version: 1, + reservation: { + id: expect.any(String), + expiresAt: expect.any(Number), + endpoints: [ + { + reference: expect.any(String), + capability: expect.any(String), + revision: 0, + }, + ], + }, + }) + expect(JSON.stringify(body)).not.toContain(sibling) + expect(JSON.stringify(body)).not.toContain("before") + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "rejects mismatched reservations while preserving idempotent group commit and conditional abort", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "external-http-commit" }) + const other = yield* withSession({ title: "external-http-other" }) + const fs = yield* FSUtil.Service + const source = path.join(path.dirname(test.directory), `external-http-source-${session.id}.txt`) + const destination = path.join(path.dirname(test.directory), `external-http-destination-${session.id}.txt`) + const partialSource = path.join(path.dirname(test.directory), `external-http-partial-source-${session.id}.txt`) + const partialDestination = path.join( + path.dirname(test.directory), + `external-http-partial-destination-${session.id}.txt`, + ) + const abortedFile = path.join(path.dirname(test.directory), `external-http-aborted-${session.id}.txt`) + yield* fs.writeWithDirs(source, "before\n") + yield* fs.writeWithDirs(partialSource, "partial-before\n") + + const prepare = (id: string, files: string[]) => + requestInDirectory(`/session/${id}/external-diff/reservations/prepare`, test.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ version: 1, files }), + }) + const lifecycle = (id: string, action: "commit" | "abort", reservation: unknown) => + requestInDirectory(`/session/${id}/external-diff/reservations/${action}`, test.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ version: 1, reservation }), + }) + type Reservation = { + id: string + expiresAt: number + endpoints: Array<{ reference: string; capability: string; revision: number }> + } + const prepared = yield* prepare(session.id, [source, destination]) + expect(prepared.status).toBe(200) + const reservation = ((yield* prepared.json) as { reservation: Reservation }).reservation + const stalePrepared = yield* prepare(session.id, [source]) + expect(stalePrepared.status).toBe(200) + const staleReservation = ((yield* stalePrepared.json) as { reservation: Reservation }).reservation + + yield* fs.writeWithDirs(destination, "before\n") + yield* fs.remove(source) + + const wrongSession = yield* lifecycle(other.id, "commit", reservation) + expect(wrongSession.status).toBe(409) + const wrongCapability = yield* lifecycle(session.id, "commit", { + ...reservation, + endpoints: [{ ...reservation.endpoints[0], capability: "forged" }, reservation.endpoints[1]], + }) + expect(wrongCapability.status).toBe(409) + const wrongReference = yield* lifecycle(session.id, "commit", { + ...reservation, + endpoints: [{ ...reservation.endpoints[0], reference: "external_forged" }, reservation.endpoints[1]], + }) + expect(wrongReference.status).toBe(409) + const committed = yield* lifecycle(session.id, "commit", reservation) + expect(committed.status).toBe(200) + expect(yield* committed.json).toEqual({ version: 1, committed: true }) + const staleRevision = yield* lifecycle(session.id, "commit", staleReservation) + expect(staleRevision.status).toBe(409) + const retry = yield* lifecycle(session.id, "commit", reservation) + expect(retry.status).toBe(200) + expect(yield* retry.json).toEqual({ version: 1, committed: true }) + const postCommitAbort = yield* lifecycle(session.id, "abort", reservation) + expect(postCommitAbort.status).toBe(409) + + const abortPrepared = yield* prepare(session.id, [abortedFile]) + expect(abortPrepared.status).toBe(200) + const aborted = yield* lifecycle( + session.id, + "abort", + ((yield* abortPrepared.json) as { reservation: Reservation }).reservation, + ) + expect(aborted.status).toBe(200) + expect(yield* aborted.json).toEqual({ version: 1, aborted: true }) + + const partial = yield* prepare(session.id, [partialSource, partialDestination]) + expect(partial.status).toBe(200) + const partialReservation = ((yield* partial.json) as { reservation: Reservation }).reservation + yield* fs.writeWithDirs(partialDestination, "partial\n") + yield* fs.remove(partialSource) + const stale = yield* lifecycle(session.id, "commit", { + ...partialReservation, + endpoints: [{ ...partialReservation.endpoints[0], revision: -1 }, partialReservation.endpoints[1]], + }) + expect(stale.status).toBe(409) + const detail = yield* requestInDirectory( + pathFor(SessionPaths.assessedDiff, { sessionID: session.id }), + test.directory, + ) + expect(detail.status).toBe(200) + expect(yield* detail.json).toEqual( + expect.objectContaining({ + assessments: expect.arrayContaining([ + expect.objectContaining({ file: partialSource, state: "unavailable" }), + expect.objectContaining({ file: partialDestination, state: "unavailable" }), + ]), + }), + ) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "returns a settled, no-store assessed diff for one non-Git sibling file", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "external-sibling" }) + const fs = yield* FSUtil.Service + const sibling = path.join(path.dirname(test.directory), `external-${session.id}.txt`) + yield* fs.writeWithDirs(sibling, "before\n") + + const reservation = ExternalDiff.prepare({ sessionID: session.id, files: [sibling] }) + expect(reservation).toBeDefined() + yield* fs.writeWithDirs(sibling, "after\n") + expect(ExternalDiff.commit({ sessionID: session.id, reservation: reservation! })).toBe(true) + + const response = yield* requestInDirectory( + pathFor(SessionPaths.assessedDiff, { sessionID: session.id }) + "?patch=true", + test.directory, + ) + expect(response.status).toBe(200) + expect(response.headers["cache-control"]).toBe("no-store") + expect(yield* response.json).toEqual({ + version: 1, + revision: 2, + assessments: [ + { + reference: reservation!.endpoints[0].reference, + file: sibling, + state: "changed", + status: "modified", + patch: expect.stringContaining("-before"), + additions: 1, + deletions: 1, + }, + ], + }) + + const other = yield* withSession({ title: "external-sibling-other-session" }) + const otherResponse = yield* requestInDirectory( + pathFor(SessionPaths.assessedDiff, { sessionID: other.id }), + test.directory, + ) + expect(otherResponse.status).toBe(200) + expect(yield* otherResponse.json).toEqual({ version: 1, revision: 0, assessments: [] }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "removes a reverted external file from the assessed changed rows", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "external-revert" }) + const fs = yield* FSUtil.Service + const sibling = path.join(path.dirname(test.directory), `external-revert-${session.id}.txt`) + yield* fs.writeWithDirs(sibling, "before\n") + + const reservation = ExternalDiff.prepare({ sessionID: session.id, files: [sibling] }) + expect(reservation).toBeDefined() + yield* fs.writeWithDirs(sibling, "after\n") + expect(ExternalDiff.commit({ sessionID: session.id, reservation: reservation! })).toBe(true) + expect(ExternalDiff.assessed(session.id).assessments).toMatchObject([{ file: sibling, state: "changed" }]) + + const revert = ExternalDiff.prepare({ sessionID: session.id, files: [sibling] }) + expect(revert).toBeDefined() + yield* fs.writeWithDirs(sibling, "before\n") + expect(ExternalDiff.commit({ sessionID: session.id, reservation: revert! })).toBe(true) + expect(ExternalDiff.assessed(session.id).assessments).toEqual([ + expect.objectContaining({ file: sibling, state: "unchanged" }), + ]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "marks a stale external owner unavailable while preserving a matching later owner", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* FSUtil.Service + const first = yield* withSession({ title: "external-first-owner" }) + const second = yield* withSession({ title: "external-second-owner" }) + const sibling = path.join(path.dirname(test.directory), `external-contention-${first.id}.txt`) + yield* fs.writeWithDirs(sibling, "baseline\n") + + const firstWrite = ExternalDiff.prepare({ sessionID: first.id, files: [sibling] })! + yield* fs.writeWithDirs(sibling, "first\n") + expect(ExternalDiff.commit({ sessionID: first.id, reservation: firstWrite })).toBe(true) + + const secondWrite = ExternalDiff.prepare({ sessionID: second.id, files: [sibling] })! + yield* fs.writeWithDirs(sibling, "second\n") + expect(ExternalDiff.commit({ sessionID: second.id, reservation: secondWrite })).toBe(true) + + expect(ExternalDiff.assessed(first.id).assessments).toEqual([ + expect.objectContaining({ file: sibling, state: "unavailable" }), + ]) + expect(ExternalDiff.assessed(second.id, { patch: true }).assessments).toEqual([ + expect.objectContaining({ file: sibling, state: "changed", patch: expect.stringContaining("+second") }), + ]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "rehydrates a committed external baseline after a same-host restart", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* FSUtil.Service + const session = yield* withSession({ title: "external-rehydrate" }) + const sibling = path.join(path.dirname(test.directory), `external-rehydrate-${session.id}.txt`) + yield* fs.writeWithDirs(sibling, "BASELINE_SECRET_975\n") + const reservation = ExternalDiff.prepare({ sessionID: session.id, files: [sibling] })! + yield* fs.writeWithDirs(sibling, "changed\n") + expect(ExternalDiff.commit({ sessionID: session.id, reservation })).toBe(true) + + ExternalDiff.resetMemoryForTest() + + expect(ExternalDiff.assessed(session.id, { patch: true }).assessments).toEqual([ + expect.objectContaining({ + file: sibling, + state: "changed", + patch: expect.stringContaining("BASELINE_SECRET_975"), + }), + ]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "keeps generated external patches out of unrequested detail and legacy diff responses", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* FSUtil.Service + const session = yield* withSession({ title: "external-egress" }) + const sibling = path.join(path.dirname(test.directory), `external-egress-${session.id}.txt`) + const sentinel = "EXTERNAL_PATCH_SENTINEL_975" + yield* fs.writeWithDirs(sibling, `${sentinel}-before\n`) + const reservation = ExternalDiff.prepare({ sessionID: session.id, files: [sibling] })! + yield* fs.writeWithDirs(sibling, `${sentinel}-after\n`) + expect(ExternalDiff.commit({ sessionID: session.id, reservation })).toBe(true) + + const detail = yield* requestInDirectory( + pathFor(SessionPaths.assessedDiff, { sessionID: session.id }), + test.directory, + ) + expect(detail.headers["cache-control"]).toBe("no-store") + expect(JSON.stringify(yield* detail.json)).not.toContain(sentinel) + expect(JSON.stringify(ExternalDiff.assessed(session.id).assessments)).not.toContain(sentinel) + + const legacy = yield* requestInDirectory(pathFor(SessionPaths.diff, { sessionID: session.id }), test.directory) + expect(legacy.status).toBe(200) + expect(yield* legacy.json).toEqual([]) + + const requested = yield* requestInDirectory( + pathFor(SessionPaths.assessedDiff, { sessionID: session.id }) + "?patch=true", + test.directory, + ) + expect(JSON.stringify(yield* requested.json)).toContain(sentinel) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "assesses a committed external creation as added and its committed deletion as unchanged", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "external-create-delete" }) + const fs = yield* FSUtil.Service + const sibling = path.join(path.dirname(test.directory), `external-new-${session.id}.txt`) + + const create = ExternalDiff.prepare({ sessionID: session.id, files: [sibling] }) + expect(create).toBeDefined() + yield* fs.writeWithDirs(sibling, "created\n") + expect(ExternalDiff.commit({ sessionID: session.id, reservation: create! })).toBe(true) + expect(ExternalDiff.assessed(session.id).assessments).toEqual([ + expect.objectContaining({ file: sibling, state: "changed", status: "added" }), + ]) + + const remove = ExternalDiff.prepare({ sessionID: session.id, files: [sibling] }) + expect(remove).toBeDefined() + yield* fs.remove(sibling) + expect(ExternalDiff.commit({ sessionID: session.id, reservation: remove! })).toBe(true) + expect(ExternalDiff.assessed(session.id).assessments).toEqual([ + expect.objectContaining({ file: sibling, state: "unchanged" }), + ]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "commits paired move endpoints atomically with distinct source and destination baselines", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "external-paired-move" }) + const fs = yield* FSUtil.Service + const source = path.join(path.dirname(test.directory), `external-source-${session.id}.txt`) + const missingDestination = path.join(path.dirname(test.directory), `external-destination-${session.id}.txt`) + const existingDestination = path.join(path.dirname(test.directory), `external-overwrite-${session.id}.txt`) + yield* fs.writeWithDirs(source, "source\n") + yield* fs.writeWithDirs(existingDestination, "destination\n") + + const missingMove = ExternalDiff.prepare({ sessionID: session.id, files: [source, missingDestination] }) + expect(missingMove).toBeDefined() + yield* fs.writeWithDirs(missingDestination, "source\n") + yield* fs.remove(source) + expect(ExternalDiff.commit({ sessionID: session.id, reservation: missingMove! })).toBe(true) + expect(ExternalDiff.assessed(session.id).assessments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: source, state: "changed", status: "deleted" }), + expect.objectContaining({ file: missingDestination, state: "changed", status: "added" }), + ]), + ) + + const overwriteMove = ExternalDiff.prepare({ + sessionID: session.id, + files: [missingDestination, existingDestination], + }) + expect(overwriteMove).toBeDefined() + yield* fs.writeWithDirs(existingDestination, "source\n") + yield* fs.remove(missingDestination) + expect(ExternalDiff.commit({ sessionID: session.id, reservation: overwriteMove! })).toBe(true) + expect(ExternalDiff.assessed(session.id).assessments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: existingDestination, state: "changed", status: "modified" }), + ]), + ) + + expect(ExternalDiff.prepare({ sessionID: session.id, files: [source, source] })).toBeUndefined() + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "aborts prepared mutations conservatively and preserves B/E/C evidence", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "external-abort" }) + const fs = yield* FSUtil.Service + const fresh = path.join(path.dirname(test.directory), `external-prepared-${session.id}.txt`) + const file = path.join(path.dirname(test.directory), `external-bec-${session.id}.txt`) + + const firstTouch = ExternalDiff.prepare({ sessionID: session.id, files: [fresh] }) + expect(ExternalDiff.assessed(session.id).assessments).toEqual([ + expect.objectContaining({ file: fresh, state: "unavailable" }), + ]) + expect(ExternalDiff.abort({ sessionID: session.id, reservation: firstTouch! })).toBe(true) + expect(ExternalDiff.assessed(session.id).assessments).toEqual([]) + + yield* fs.writeWithDirs(file, "B\n") + const committed = ExternalDiff.prepare({ sessionID: session.id, files: [file] }) + yield* fs.writeWithDirs(file, "E\n") + expect(ExternalDiff.commit({ sessionID: session.id, reservation: committed! })).toBe(true) + + const restored = ExternalDiff.prepare({ sessionID: session.id, files: [file] }) + yield* fs.writeWithDirs(file, "B\n") + expect(ExternalDiff.abort({ sessionID: session.id, reservation: restored! })).toBe(true) + expect(ExternalDiff.assessed(session.id).assessments).toEqual([ + expect.objectContaining({ file, state: "unchanged" }), + ]) + + const retained = ExternalDiff.prepare({ sessionID: session.id, files: [file] }) + yield* fs.writeWithDirs(file, "E\n") + expect(ExternalDiff.abort({ sessionID: session.id, reservation: retained! })).toBe(true) + expect(ExternalDiff.assessed(session.id).assessments).toEqual([ + expect.objectContaining({ file, state: "changed", status: "modified" }), + ]) + + const partial = ExternalDiff.prepare({ sessionID: session.id, files: [file] }) + yield* fs.writeWithDirs(file, "C\n") + expect(ExternalDiff.abort({ sessionID: session.id, reservation: partial! })).toBe(true) + expect(ExternalDiff.assessed(session.id).assessments).toEqual([ + expect.objectContaining({ file, state: "unavailable" }), + ]) + + yield* fs.writeWithDirs(file, "B\n") + const expectedBaseline = ExternalDiff.prepare({ sessionID: session.id, files: [file] }) + expect(ExternalDiff.commit({ sessionID: session.id, reservation: expectedBaseline! })).toBe(true) + expect(ExternalDiff.assessed(session.id).assessments).toEqual([ + expect.objectContaining({ file, state: "unchanged" }), + ]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "rejects invalid reservation commits without partially replacing expected state", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "external-invalid-reservations" }) + const fs = yield* FSUtil.Service + const source = path.join(path.dirname(test.directory), `external-invalid-source-${session.id}.txt`) + const destination = path.join(path.dirname(test.directory), `external-invalid-destination-${session.id}.txt`) + yield* fs.writeWithDirs(source, "before\n") + + const initial = ExternalDiff.prepare({ sessionID: session.id, files: [source] })! + yield* fs.writeWithDirs(source, "expected\n") + expect(ExternalDiff.commit({ sessionID: session.id, reservation: initial })).toBe(true) + const before = ExternalDiff.assessed(session.id) + + const stale = ExternalDiff.prepare({ sessionID: session.id, files: [source] })! + const winning = ExternalDiff.prepare({ sessionID: session.id, files: [source] })! + yield* fs.writeWithDirs(source, "winning\n") + expect(ExternalDiff.commit({ sessionID: session.id, reservation: winning })).toBe(true) + const committed = ExternalDiff.assessed(session.id) + expect(ExternalDiff.commit({ sessionID: session.id, reservation: stale })).toBe(false) + expect(ExternalDiff.commit({ sessionID: "other-session", reservation: stale })).toBe(false) + expect( + ExternalDiff.commit({ + sessionID: session.id, + reservation: { ...stale, id: "forged", endpoints: [...stale.endpoints] }, + }), + ).toBe(false) + expect( + ExternalDiff.commit({ + sessionID: session.id, + reservation: { + ...stale, + endpoints: [{ ...stale.endpoints[0], reference: "wrong-reference" }], + }, + }), + ).toBe(false) + const expired = ExternalDiff.prepare({ sessionID: session.id, files: [source], ttlMs: -1 })! + expect(ExternalDiff.commit({ sessionID: session.id, reservation: expired })).toBe(false) + expect(before.assessments[0]).toMatchObject({ state: "changed" }) + expect(ExternalDiff.assessed(session.id)).toEqual(committed) + + const group = ExternalDiff.prepare({ sessionID: session.id, files: [source, destination] })! + yield* fs.writeWithDirs(destination, "partial\n") + yield* fs.remove(source) + expect( + ExternalDiff.commit({ + sessionID: session.id, + reservation: { ...group, endpoints: [{ ...group.endpoints[0], revision: -1 }, group.endpoints[1]] }, + }), + ).toBe(false) + expect(ExternalDiff.assessed(session.id).assessments).toEqual( + expect.arrayContaining([expect.objectContaining({ file: destination, state: "unavailable" })]), + ) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "does not publish one endpoint when a paired reservation cannot prepare the other", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "external-prepare-atomic" }) + const fs = yield* FSUtil.Service + const source = path.join(path.dirname(test.directory), `external-prepare-source-${session.id}.txt`) + const unreadableDestination = path.join( + path.dirname(test.directory), + `external-prepare-destination-${session.id}`, + ) + yield* fs.writeWithDirs(source, "before\n") + yield* fs.makeDirectory(unreadableDestination) + + expect(ExternalDiff.prepare({ sessionID: session.id, files: [source, unreadableDestination] })).toBeUndefined() + expect(ExternalDiff.assessed(session.id).assessments).toEqual([]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + it.instance( "returns [] for session with no messages", () => @@ -140,10 +659,7 @@ describe("Session.diff — session-scoped agent diffs (#174)", () => { yield* fs.writeWithDirs(path.join(test.directory, "external.txt"), "external change") // Record a patch part (provides snapshot hash range, but files no longer feed the filter) - const agentFiles = [ - path.join(test.directory, "new-file.ts"), - path.join(test.directory, "existing.txt"), - ] + const agentFiles = [path.join(test.directory, "new-file.ts"), path.join(test.directory, "existing.txt")] yield* Session.use.updatePart({ id: PartID.ascending(), sessionID: session.id, @@ -520,9 +1036,7 @@ describe("Session.diff — session-scoped agent diffs (#174)", () => { agent: "plan", model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") }, summary: { - diffs: [ - { file: "foreign-edit.ts", additions: 1, deletions: 0, status: "added" as const }, - ], + diffs: [{ file: "foreign-edit.ts", additions: 1, deletions: 0, status: "added" as const }], }, } satisfies SessionV1.User) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index d109181986..86086eb586 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -16,6 +16,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" +import { ExternalDiff } from "@/session/external-diff" +import path from "path" const it = testEffect( AppNodeBuilder.build( @@ -250,4 +252,28 @@ describe("Session", () => { expect(saved.metadata).toBeUndefined() }), ) + + it.instance("forks and recursively removes external ownership without inheriting host-local baselines", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const dir = yield* tmpdirScoped({ git: true }) + const parent = yield* provideInstance(dir)(session.create({ title: "external-owner" })) + const external = path.join(path.dirname(dir), `external-owner-${parent.id}.txt`) + yield* Effect.promise(() => Bun.write(external, "baseline\n")) + const reservation = ExternalDiff.prepare({ sessionID: parent.id, files: [external] })! + yield* Effect.promise(() => Bun.write(external, "changed\n")) + expect(ExternalDiff.commit({ sessionID: parent.id, reservation })).toBe(true) + + const fork = yield* provideInstance(dir)(session.fork({ sessionID: parent.id })) + expect(ExternalDiff.assessed(fork.id).assessments).toEqual([]) + + yield* session.remove(parent.id) + ExternalDiff.resetMemoryForTest() + expect(ExternalDiff.assessed(parent.id).assessments).toEqual([]) + expect(ExternalDiff.assessed(fork.id).assessments).toEqual([]) + expect(ExternalDiff.commit({ sessionID: parent.id, reservation })).toBe(false) + ExternalDiff.sweep() + ExternalDiff.sweep() + }), + ) }) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index fe036be8c9..7a08dbebe4 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -18,6 +18,8 @@ import { eq } from "drizzle-orm" import { provideTmpdirInstance } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" import { pollWithTimeout, testEffect } from "../lib/effect" +import { ExternalDiff } from "@/session/external-diff" +import path from "path" const env = LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node])) const it = testEffect(env) @@ -175,6 +177,54 @@ describe("ShareNext", () => { ), ) + it.live("never sends a generated external patch through the sharing queue", () => + provideTmpdirInstance( + (dir) => { + const seen: string[] = [] + const client = HttpClient.make((req) => { + if (req.url.endsWith("/sync") && req.body._tag === "Uint8Array") { + seen.push(new TextDecoder().decode(req.body.body)) + } + return Effect.succeed(json(req, { ok: true })) + }) + return Effect.gen(function* () { + const share = yield* ShareNext.Service + const session = yield* Session.Service + const info = yield* session.create({ title: "external-share" }) + const sentinel = "EXTERNAL_SHARE_SENTINEL_975" + const external = path.join(path.dirname(dir), `external-share-${info.id}.txt`) + yield* Effect.promise(() => Bun.write(external, `${sentinel}-before\n`)) + const reservation = ExternalDiff.prepare({ sessionID: info.id, files: [external] })! + yield* Effect.promise(() => Bun.write(external, `${sentinel}-after\n`)) + expect(ExternalDiff.commit({ sessionID: info.id, reservation })).toBe(true) + expect(JSON.stringify(ExternalDiff.assessed(info.id, { patch: true }))).toContain(sentinel) + + yield* share.init() + const { db } = yield* Database.Service + yield* db + .insert(SessionShareTable) + .values({ + session_id: info.id, + id: "shr_external", + url: "https://legacy-share.example.com/share/external", + secret: "sec_975", + }) + .run() + .pipe(Effect.orDie) + yield* session.setTitle({ sessionID: info.id, title: "external-share-updated" }) + yield* pollWithTimeout( + Effect.sync(() => (seen.length === 1 ? true : undefined)), + "timed out waiting for share sync", + "5 seconds", + ) + expect(seen).toHaveLength(1) + expect(seen[0]).not.toContain(sentinel) + }).pipe(Effect.provide(integrationLayer(client))) + }, + { config: { enterprise: { url: "https://legacy-share.example.com" } } }, + ), + ) + it.live("remove deletes the persisted share and calls the delete endpoint", () => provideTmpdirInstance( () => { diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index e394d8084f..6a68c9eb80 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -12,6 +12,7 @@ import { EventV2Bridge } from "../../src/event-v2-bridge" import { Truncate } from "@/tool/truncate" import { TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" +import { ExternalDiff } from "../../src/session/external-diff" import { testEffect } from "../lib/effect" const it = testEffect( @@ -86,6 +87,46 @@ const expectFailure = (effect: Effect.Effect, message?: string const expectReadFailure = (filepath: string) => expectFailure(readText(filepath)) describe("tool.apply_patch freeform", () => { + it.instance("prepares paired external move endpoints before mutation and commits their assessed lifecycle", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const source = path.join(path.dirname(test.directory), `external-source-${Date.now()}.txt`) + const destination = path.join(path.dirname(test.directory), `external-destination-${Date.now()}.txt`) + yield* writeText(source, "before\n") + + yield* execute( + { + patchText: `*** Begin Patch\n*** Update File: ${source}\n*** Move to: ${destination}\n@@\n-before\n+after\n*** End Patch`, + }, + ctx, + ) + + const assessments = ExternalDiff.assessed(ctx.sessionID).assessments + expect(assessments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: source, state: "changed", status: "deleted" }), + expect.objectContaining({ file: destination, state: "changed", status: "added" }), + ]), + ) + }), + ) + + it.instance("commits a registered external deletion from its verified absent endpoint", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const file = path.join(path.dirname(test.directory), `external-delete-${Date.now()}.txt`) + yield* writeText(file, "before\n") + + yield* execute({ patchText: `*** Begin Patch\n*** Delete File: ${file}\n*** End Patch` }, ctx) + + expect(ExternalDiff.assessed(ctx.sessionID).assessments.find((entry) => entry.file === file)).toEqual( + expect.objectContaining({ state: "changed", status: "deleted" }), + ) + }), + ) + it.live("requires patchText", () => Effect.gen(function* () { const { ctx } = makeCtx() diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 46155a5091..e7967de6c8 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 { EventV2Bridge } from "../../src/event-v2-bridge" import { Truncate } from "@/tool/truncate" import { SessionID, MessageID } from "../../src/session/schema" +import { ExternalDiff } from "../../src/session/external-diff" import * as Tool from "../../src/tool/tool" import { testEffect } from "../lib/effect" import { Watcher } from "@opencode-ai/core/filesystem/watcher" @@ -142,6 +143,26 @@ describe("tool.edit", () => { }) describe("editing existing files", () => { + it.instance("captures and settles an external edit after permission", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(path.dirname(test.directory), `external-edit-${Date.now()}.txt`) + yield* put(filepath, "before\n") + + yield* run({ filePath: filepath, oldString: "before", newString: "after" }) + + const assessment = ExternalDiff.assessed(ctx.sessionID, { patch: true }).assessments.find( + (entry) => entry.file === filepath, + ) + expect(assessment).toMatchObject({ + state: "changed", + patch: expect.stringContaining("-before"), + additions: 1, + deletions: 1, + }) + }), + ) + it.instance("replaces text in existing file", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index c797a1ea8a..1407181dcc 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -12,6 +12,7 @@ import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" +import { ExternalDiff } from "../../src/session/external-diff" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -112,6 +113,111 @@ describe("tool.write", () => { }) describe("existing file overwrite", () => { + it.instance("captures a sibling baseline only after permission and settles the formatted result", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(path.dirname(test.directory), `external-${Date.now()}.txt`) + yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8")) + let assessmentExistedBeforePermission = false + const result = yield* run( + { filePath: filepath, content: "after\n" }, + { + ...ctx, + ask: () => + Effect.sync(() => { + assessmentExistedBeforePermission ||= ExternalDiff.assessed(ctx.sessionID).assessments.some( + (entry) => entry.file === filepath, + ) + }), + }, + ) + + const assessment = ExternalDiff.assessed(ctx.sessionID, { patch: true }).assessments.find( + (entry) => entry.file === filepath, + ) + expect(assessmentExistedBeforePermission).toBe(false) + expect(assessment).toMatchObject({ + state: "changed", + patch: expect.stringContaining("-before"), + additions: 1, + deletions: 1, + }) + expect(result.metadata).not.toHaveProperty("baseline") + }), + ) + + it.instance("does not capture a sibling baseline when permission is denied", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(path.dirname(test.directory), `denied-${Date.now()}.txt`) + yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8")) + + const exit = yield* run( + { filePath: filepath, content: "after\n" }, + { + ...ctx, + ask: () => + Effect.sync(() => { + throw new Error("permission denied") + }), + }, + ).pipe(Effect.exit) + + expect(exit._tag).toBe("Failure") + expect(ExternalDiff.assessed(ctx.sessionID).assessments.some((entry) => entry.file === filepath)).toBe(false) + }), + ) + + it.instance("commits an external creation from the server-verified endpoint state", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(path.dirname(test.directory), `created-${Date.now()}.txt`) + + yield* run({ filePath: filepath, content: "created\n" }) + + expect(ExternalDiff.assessed(ctx.sessionID).assessments.find((entry) => entry.file === filepath)).toEqual( + expect.objectContaining({ state: "changed", status: "added" }), + ) + }), + ) + + it.instance( + "assesses formatter output instead of the pre-format tool patch", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(path.dirname(test.directory), `formatted-${Date.now()}.fmt`) + yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8")) + + yield* run({ filePath: filepath, content: "after\n" }) + + const assessment = ExternalDiff.assessed(ctx.sessionID, { patch: true }).assessments.find( + (entry) => entry.file === filepath, + ) + expect(assessment).toMatchObject({ + state: "changed", + patch: expect.stringContaining("+formatter"), + additions: 2, + deletions: 1, + }) + }), + { + config: { + formatter: { + append: { + extensions: [".fmt"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.appendFileSync(file, 'formatter\\n')", + "$FILE", + ], + }, + }, + }, + }, + ) + it.instance("overwrites existing file content", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 2536ea182f..0f5281cb75 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -179,6 +179,8 @@ import type { QuestionV2Reply, SessionAbortErrors, SessionAbortResponses, + SessionAssessedDiffErrors, + SessionAssessedDiffResponses, SessionChildrenErrors, SessionChildrenResponses, SessionCommandErrors, @@ -191,6 +193,12 @@ import type { SessionDeleteResponses, SessionDiffErrors, SessionDiffResponses, + SessionExternalReservationAbortErrors, + SessionExternalReservationAbortResponses, + SessionExternalReservationCommitErrors, + SessionExternalReservationCommitResponses, + SessionExternalReservationPrepareErrors, + SessionExternalReservationPrepareResponses, SessionForkErrors, SessionForkResponses, SessionGetErrors, @@ -3742,6 +3750,191 @@ export class Session2 extends HeyApiClient { }) } + /** + * Get server-assessed external file diffs + * + * Get current session-owned external file assessments. + */ + public assessedDiff( + parameters: { + sessionID: string + directory?: string + workspace?: string + patch?: "true" | "false" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "patch" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/diff/assessed", + ...options, + ...params, + }) + } + + /** + * Prepare an external mutation reservation + * + * Capture server-owned external baselines and return opaque reservation capabilities. + */ + public externalReservationPrepare( + parameters: { + sessionID: string + directory?: string + workspace?: string + version?: 1 + files?: Array + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "version" }, + { in: "body", key: "files" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + SessionExternalReservationPrepareResponses, + SessionExternalReservationPrepareErrors, + ThrowOnError + >({ + url: "/session/{sessionID}/external-diff/reservations/prepare", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Commit an external mutation reservation + * + * Atomically record server-verified endpoint state for an opaque reservation group. + */ + public externalReservationCommit( + parameters: { + sessionID: string + directory?: string + workspace?: string + version?: 1 + reservation?: { + id: string + expiresAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + endpoints: Array<{ + reference: string + capability: string + revision: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + }> + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "version" }, + { in: "body", key: "reservation" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + SessionExternalReservationCommitResponses, + SessionExternalReservationCommitErrors, + ThrowOnError + >({ + url: "/session/{sessionID}/external-diff/reservations/commit", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Abort an external mutation reservation + * + * Conditionally discard a prepared opaque reservation without replacing committed state. + */ + public externalReservationAbort( + parameters: { + sessionID: string + directory?: string + workspace?: string + version?: 1 + reservation?: { + id: string + expiresAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + endpoints: Array<{ + reference: string + capability: string + revision: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + }> + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "version" }, + { in: "body", key: "reservation" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + SessionExternalReservationAbortResponses, + SessionExternalReservationAbortErrors, + ThrowOnError + >({ + url: "/session/{sessionID}/external-diff/reservations/abort", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Get touched files * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fb467d191e..223a553f8f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1954,6 +1954,7 @@ export type Config = { model?: string small_model?: string default_agent?: string + agent_order?: Array subagent_depth?: number username?: string mode?: { @@ -2553,6 +2554,7 @@ export type ProviderAuthError1 = { | "ProviderAuthOauthMissing" | "ProviderAuthOauthCodeMissing" | "ProviderAuthOauthCallbackFailed" + | "ProviderAuthOauthAuthorizationFailed" | "ProviderAuthValidationFailed" data: { providerID?: string @@ -2569,6 +2571,12 @@ export type NotFoundError = { } } +export type ConflictError = { + _tag: "ConflictError" + message: string + resource?: string +} + export type TextPartInput = { id?: string type: "text" @@ -2746,12 +2754,6 @@ export type PromptInput = { agents?: Array } -export type ConflictError = { - _tag: "ConflictError" - message: string - resource?: string -} - export type ServiceUnavailableError = { _tag: "ServiceUnavailableError" message: string @@ -9833,6 +9835,215 @@ export type SessionDiffResponses = { export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] +export type SessionAssessedDiffData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + patch?: "true" | "false" + } + url: "/session/{sessionID}/diff/assessed" +} + +export type SessionAssessedDiffErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionAssessedDiffError = SessionAssessedDiffErrors[keyof SessionAssessedDiffErrors] + +export type SessionAssessedDiffResponses = { + /** + * Server-assessed external file diffs + */ + 200: { + version: 1 + revision: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + assessments: Array<{ + reference: string + file: string + state: "changed" | "unchanged" | "unavailable" + status?: "added" | "modified" | "deleted" + patch?: string + additions?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + deletions?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + }> + } +} + +export type SessionAssessedDiffResponse = SessionAssessedDiffResponses[keyof SessionAssessedDiffResponses] + +export type SessionExternalReservationPrepareData = { + body?: { + version: 1 + files: Array + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/external-diff/reservations/prepare" +} + +export type SessionExternalReservationPrepareErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError + /** + * ConflictError + */ + 409: ConflictError +} + +export type SessionExternalReservationPrepareError = + SessionExternalReservationPrepareErrors[keyof SessionExternalReservationPrepareErrors] + +export type SessionExternalReservationPrepareResponses = { + /** + * Prepared opaque external mutation reservation + */ + 200: { + version: 1 + reservation: { + id: string + expiresAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + endpoints: Array<{ + reference: string + capability: string + revision: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + }> + } + } +} + +export type SessionExternalReservationPrepareResponse = + SessionExternalReservationPrepareResponses[keyof SessionExternalReservationPrepareResponses] + +export type SessionExternalReservationCommitData = { + body?: { + version: 1 + reservation: { + id: string + expiresAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + endpoints: Array<{ + reference: string + capability: string + revision: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + }> + } + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/external-diff/reservations/commit" +} + +export type SessionExternalReservationCommitErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError + /** + * ConflictError + */ + 409: ConflictError +} + +export type SessionExternalReservationCommitError = + SessionExternalReservationCommitErrors[keyof SessionExternalReservationCommitErrors] + +export type SessionExternalReservationCommitResponses = { + /** + * Committed external mutation reservation + */ + 200: { + version: 1 + committed: true + } +} + +export type SessionExternalReservationCommitResponse = + SessionExternalReservationCommitResponses[keyof SessionExternalReservationCommitResponses] + +export type SessionExternalReservationAbortData = { + body?: { + version: 1 + reservation: { + id: string + expiresAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + endpoints: Array<{ + reference: string + capability: string + revision: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + }> + } + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/external-diff/reservations/abort" +} + +export type SessionExternalReservationAbortErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError + /** + * ConflictError + */ + 409: ConflictError +} + +export type SessionExternalReservationAbortError = + SessionExternalReservationAbortErrors[keyof SessionExternalReservationAbortErrors] + +export type SessionExternalReservationAbortResponses = { + /** + * Aborted external mutation reservation + */ + 200: { + version: 1 + aborted: true + } +} + +export type SessionExternalReservationAbortResponse = + SessionExternalReservationAbortResponses[keyof SessionExternalReservationAbortResponses] + export type SessionTouchedFilesData = { body?: never path: { diff --git a/packages/sdk/js/test/session-history.test.ts b/packages/sdk/js/test/session-history.test.ts index 44a9743331..c04f6dfaea 100644 --- a/packages/sdk/js/test/session-history.test.ts +++ b/packages/sdk/js/test/session-history.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import type { V2SessionHistoryData } from "../src/v2/gen/types.gen" +import type { SessionAssessedDiffData, V2SessionHistoryData } from "../src/v2/gen/types.gen" test("uses numeric Session history positions", () => { const input = { @@ -10,3 +10,13 @@ test("uses numeric Session history positions", () => { expect(input.query.after).toBe(1) }) + +test("requests generated external patches only explicitly", () => { + const input = { + path: { sessionID: "ses_test" }, + query: { patch: "true" }, + url: "/session/{sessionID}/diff/assessed", + } satisfies SessionAssessedDiffData + + expect(input.query.patch).toBe("true") +})