From a8af93c048b0fe3a592dc3f6e03c5152b80c2f56 Mon Sep 17 00:00:00 2001 From: Agent Runtime Fixture Date: Sun, 27 Sep 2026 23:38:08 +0100 Subject: [PATCH 1/2] feat(opencode): push native turn-diff evidence to Review (#1625) --- .../opencode-provider-turn-diff.test.ts | 145 +++++++++++ .../adapters/opencode/opencode-provider.ts | 46 +++- apps/web/src/components/diff/LastTurnView.tsx | 28 ++ .../__tests__/turn-diff-conformance.test.ts | 52 +++- packages/providers/src/index.ts | 5 + .../opencode-native-turn-diff.test.ts | 105 ++++++++ .../opencode/opencode-native-turn-diff.ts | 244 ++++++++++++++++++ 7 files changed, 620 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-turn-diff.test.ts create mode 100644 packages/providers/src/private/opencode/__tests__/opencode-native-turn-diff.test.ts create mode 100644 packages/providers/src/private/opencode/opencode-native-turn-diff.ts diff --git a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-turn-diff.test.ts b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-turn-diff.test.ts new file mode 100644 index 000000000..8ee9d0aa2 --- /dev/null +++ b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-turn-diff.test.ts @@ -0,0 +1,145 @@ +import "reflect-metadata"; +import { describe, expect, it, vi } from "vitest"; +import { OpenCodeProvider } from "../opencode-provider.js"; +import { OpenCodeServerPool } from "../opencode-server-pool.js"; +import type { ProviderTurnDiffUpdate, TurnRequest } from "@mcode/contracts"; + +function testProvider(http: never, pool: OpenCodeServerPool) { + const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" } } }) }; + const envService = { getEnv: () => ({}) }; + const host = { + events: { submit: async () => ({ commit: {}, delivery: { ingress: "queued" } }) }, + processes: { attach: () => {}, terminateTree: async () => {} }, + runtime: { platform: "win32" }, + environment: { snapshot: () => ({}) }, + browser: {}, + threadControl: {}, + grants: {}, + }; + const provider = new OpenCodeProvider(settingsService as never, envService as never, host as never); + provider.configureTestSeams({ + pool, + http: http as never, + probeCli: async () => ({ binaryPath: "opencode", version: "test" }), + idleConfirm: { intervalMs: 5, requiredPolls: 2, timeoutMs: 500, maxPollErrors: 2 }, + }); + return provider; +} + +function testPool(): OpenCodeServerPool { + return new OpenCodeServerPool({ + spawn: () => ({ pid: 1, on: () => {}, off: () => {}, kill: () => true }) as never, + waitForHealth: async () => {}, + terminateTree: async () => {}, + findFreePort: async () => 4096, + now: () => Date.now(), + env: () => ({}), + }); +} + +function turnRequest(): TurnRequest<"opencode"> { + return { + turnId: "turn-1", + turnExecutionId: "11111111-1111-4111-8111-111111111111", + sessionId: "mcode-thread-1", + workspaceId: "ws-1", + threadId: "thread-1", + message: "hello", + cwd: "/w/a", + model: "anthropic/claude-sonnet-4-6", + permissionMode: "full", + interactionMode: "build", + providerOptions: {}, + } as TurnRequest<"opencode">; +} + +/** Full-context upstream patch for one changed line. */ +function diffEvent(file: string, before: string, after: string) { + return { + type: "session.diff", + properties: { + sessionID: "ses_1", + diff: [{ + file, + patch: [ + `Index: ${file}`, + "===================================================================", + `--- ${file}`, + `+++ ${file}`, + "@@ -1,1 +1,1 @@", + `-${before}`, + `+${after}`, + "", + ].join("\n"), + additions: 1, + deletions: 1, + status: "modified", + }], + }, + }; +} + +function fakeHttp(envelopes: unknown[]) { + return { + createSession: vi.fn(async () => ({ id: "ses_1" })), + promptAsync: vi.fn(async () => {}), + abortSession: vi.fn(async () => {}), + listModels: vi.fn(async () => []), + listSessionMessages: vi.fn(async () => []), + getSessionStatus: vi.fn(async () => new Proxy({}, { get: () => ({ type: "idle" }) })), + subscribeEvents: vi.fn(async (_url: string, _signal: AbortSignal, onEnvelope: (e: unknown) => void) => { + for (const envelope of envelopes) onEnvelope(envelope); + onEnvelope({ type: "session.idle", properties: { sessionID: "ses_1" } }); + }), + }; +} + +describe("OpenCodeProvider native turn diff", () => { + it("pushes a complete native snapshot keyed by the dispatched turn identity", async () => { + const provider = testProvider(fakeHttp([diffEvent("notes.txt", "agent marker: old", "agent marker: new")]) as never, testPool()); + const updates: ProviderTurnDiffUpdate[] = []; + provider.onTurnDiff((event) => updates.push(event)); + await provider.sendTurn(turnRequest()); + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ + turnId: "turn-1", + turnExecutionId: "11111111-1111-4111-8111-111111111111", + deliveryAttempt: 1, + revision: 1, + state: "snapshot", + nativeFidelity: "agent", + }); + const patch = (updates[0] as { patch: string }).patch; + expect(patch).toContain("diff --git a/notes.txt b/notes.txt"); + expect(patch).toContain("-agent marker: old"); + expect(patch).toContain("+agent marker: new"); + provider.shutdown(); + }); + + it("ignores a diff event owned by another upstream session", async () => { + const foreign = diffEvent("notes.txt", "a", "b"); + foreign.properties.sessionID = "ses_other"; + const provider = testProvider(fakeHttp([foreign]) as never, testPool()); + const updates: ProviderTurnDiffUpdate[] = []; + provider.onTurnDiff((event) => updates.push(event)); + await provider.sendTurn(turnRequest()); + expect(updates).toHaveLength(0); + provider.shutdown(); + }); + + it("pushes rejected evidence whole when one entry is unusable", async () => { + const bad = { type: "session.diff", properties: { sessionID: "ses_1", diff: [{ file: "blob.bin", additions: 0, deletions: 0 }] } }; + const provider = testProvider(fakeHttp([diffEvent("notes.txt", "a", "b"), bad]) as never, testPool()); + const updates: ProviderTurnDiffUpdate[] = []; + provider.onTurnDiff((event) => updates.push(event)); + await provider.sendTurn(turnRequest()); + expect(updates.map((update) => update.state)).toEqual(["snapshot", "rejected"]); + provider.shutdown(); + }); + + it("declares the turn-diff capability", () => { + const provider = testProvider(fakeHttp([]) as never, testPool()); + expect(provider.descriptor.capabilities).toContainEqual({ name: "turn-diff", support: "supported" }); + provider.shutdown(); + }); +}); diff --git a/apps/server/src/features/providers/adapters/opencode/opencode-provider.ts b/apps/server/src/features/providers/adapters/opencode/opencode-provider.ts index 623e5935f..93e90dca6 100644 --- a/apps/server/src/features/providers/adapters/opencode/opencode-provider.ts +++ b/apps/server/src/features/providers/adapters/opencode/opencode-provider.ts @@ -12,11 +12,13 @@ import type { ProviderId, ProviderModelInfo, ProviderIdentity, + ProviderTurnDiffUpdate, SessionForker, TurnRequest, } from "@mcode/contracts"; import { AgentEventType, providerRuntimeEvent } from "@mcode/contracts"; import type { ProviderHostPorts } from "@mcode/providers"; +import { OpenCodeNativeTurnDiff } from "@mcode/providers"; import { SettingsService } from "../../../settings/settings-service.js"; import { EnvService } from "../../../../runtime/environment/env-service.js"; import { CleanForker } from "../../../handoff/index.js"; @@ -40,7 +42,7 @@ import { import { formatOpenCodeResumeCursor, parseOpenCodeResumeCursor } from "./opencode-resume-cursor.js"; import { probeOpenCodeCli } from "./opencode-cli.js"; -const OPENCODE_SUPPORTED_CAPABILITIES = ["build", "plan", "permissions", "session-eviction"] as const; +const OPENCODE_SUPPORTED_CAPABILITIES = ["build", "plan", "permissions", "session-eviction", "turn-diff"] as const; /** Visible notice when a missing upstream session forces a fresh start. */ const OPENCODE_SESSION_INVALIDATED_SUBTYPE = "sdk_session_invalidated"; @@ -113,6 +115,9 @@ interface OpenCodeTurnState { messageRoles: Map; /** Text already forwarded per message, for exactly-once streaming. */ forwardedText: Map; + /** Native turn-diff accumulator and its monotonic push cursor for this turn. */ + nativeDiff: OpenCodeNativeTurnDiff; + nativeDiffRevision: number; } function nestedSessionId(holder: unknown): string | undefined { @@ -259,6 +264,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP private readonly canonicalEventPublisher: CanonicalLiveEventPublisher | undefined; private readonly pendingPermissions = new Map(); private readonly seenNotices = new Map>(); + private readonly turnDiffListeners = new Set<(event: ProviderTurnDiffUpdate) => void>(); private pool: OpenCodeServerPool; private http: OpenCodeHttpClient; private probeCli: (cliPath: string, platform: string) => Promise<{ binaryPath: string; version: string }>; @@ -547,6 +553,8 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP idleConfirmPromise: null, messageRoles: new Map(), forwardedText: new Map(), + nativeDiff: new OpenCodeNativeTurnDiff(), + nativeDiffRevision: 0, }; this.turns.set(sessionId, created); return created; @@ -594,6 +602,8 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP state.abortController = new AbortController(); state.messageRoles.clear(); state.forwardedText.clear(); + state.nativeDiff = new OpenCodeNativeTurnDiff(); + state.nativeDiffRevision = 0; emit({ type: AgentEventType.TurnStarted, threadId } satisfies AgentEvent); const entry = await this.acquireTurnEntry(req, routing, state, emit); if (!entry) { @@ -744,6 +754,37 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP emit({ type: AgentEventType.System, threadId, subtype: `sdk_session_id:${created.id}` } satisfies AgentEvent); } + /** Subscribe to complete native turn diffs without routing their bytes through renderer events. */ + onTurnDiff(handler: (event: ProviderTurnDiffUpdate) => void): () => void { + this.turnDiffListeners.add(handler); + return () => this.turnDiffListeners.delete(handler); + } + + /** + * Fold one upstream `session.diff` event into the turn's native aggregate + * and push the complete result. Upstream diffs are computed between its own + * step snapshots, so they carry agent-attributed evidence; the event mapper + * keeps classifying these envelopes as noise for the narrative timeline. + */ + private publishNativeTurnDiff( + req: TurnRequest<"opencode">, + state: OpenCodeTurnState, + properties: Record, + ): void { + const result = state.nativeDiff.observe(Array.isArray(properties.diff) ? properties.diff : []); + if (result === null) return; + const identity = { + turnId: req.turnId, + turnExecutionId: req.turnExecutionId, + deliveryAttempt: req.deliveryAttempt ?? 1, + revision: ++state.nativeDiffRevision, + }; + const event: ProviderTurnDiffUpdate = result.state === "snapshot" + ? { ...identity, ...result, nativeFidelity: "agent" } + : { ...identity, ...result }; + for (const listener of this.turnDiffListeners) listener(event); + } + private handleTurnEnvelope( envelope: unknown, req: TurnRequest<"opencode">, @@ -759,6 +800,9 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP this.trackMessageRole(state, normalized); const owner = sessionIdOfNormalized(normalized); if (owner && owner !== upstreamId) return; + if (normalized.type === "session.diff") { + this.publishNativeTurnDiff(req, state, normalized.properties); + } } const mapped = mapOpenCodeEnvelope(envelope, { threadId, diff --git a/apps/web/src/components/diff/LastTurnView.tsx b/apps/web/src/components/diff/LastTurnView.tsx index c70e0a015..162acde9f 100644 --- a/apps/web/src/components/diff/LastTurnView.tsx +++ b/apps/web/src/components/diff/LastTurnView.tsx @@ -1,5 +1,6 @@ import type { ReviewComparison } from "@mcode/contracts"; import { FileList } from "./FileList"; +import { Badge } from "@/components/ui/badge"; /** Props for LastTurnView. */ interface LastTurnViewProps { @@ -35,6 +36,15 @@ export function LastTurnView({ threadId, comparison, cacheVersion, refreshing, o return (
+
+ + {comparison.files.length} + + + {turnLabel(comparison)} + + +
); } + +function turnLabel(comparison: ReviewComparison): string { + const files = comparison.files.length === 1 ? "file" : "files"; + const phase = comparison.turnDiff?.phase === "live" ? "Live" : "last turn"; + return `${files} ยท ${phase}`; +} + +function TurnDiffSource({ comparison }: { comparison: ReviewComparison }) { + if (!comparison.turnDiff) return null; + return + {comparison.turnDiff.source === "git" ? "Git fallback: same-file edits may appear" : comparison.turnDiff.source === "tracked" ? "Tracked file evidence" : "Agent changes"} + ; +} diff --git a/packages/providers/src/conformance/__tests__/turn-diff-conformance.test.ts b/packages/providers/src/conformance/__tests__/turn-diff-conformance.test.ts index f2552e69a..5a137dc7c 100644 --- a/packages/providers/src/conformance/__tests__/turn-diff-conformance.test.ts +++ b/packages/providers/src/conformance/__tests__/turn-diff-conformance.test.ts @@ -1,26 +1,70 @@ import { describe, expect, it } from "vitest"; import { nativeTurnDiffEvidence } from "../../private/codex/codex-provider.js"; import { CursorNativeTurnDiff } from "../../private/cursor/acp/cursor-native-turn-diff.js"; +import { OpenCodeNativeTurnDiff } from "../../private/opencode/opencode-native-turn-diff.js"; const patch = "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1,1 +1,1 @@\n-before\n+after\n"; +/** Full-context upstream OpenCode patch for one changed line. */ +function openCodeDiffEvent(file: string, before: string, after: string) { + return [{ + file, + patch: [ + `Index: ${file}`, + "===================================================================", + `--- ${file}`, + `+++ ${file}`, + "@@ -1,1 +1,1 @@", + `-${before}`, + `+${after}`, + "", + ].join("\n"), + additions: 1, + deletions: 1, + status: "modified", + }]; +} + +function openCodeEvidence(entries: readonly unknown[]) { + return new OpenCodeNativeTurnDiff().observe(entries); +} + describe("provider-neutral native diff conformance", () => { it("rejects malformed Codex evidence so the service can select fallback", () => { expect(nativeTurnDiffEvidence(42)).toEqual({ state: "rejected" }); }); - it.each(["codex", "cursor"])("%s supplies the same complete aggregate", (provider) => { + it.each(["codex", "cursor", "opencode"])("%s supplies the same complete aggregate", (provider) => { const evidence = provider === "codex" ? nativeTurnDiffEvidence(patch) - : new CursorNativeTurnDiff().push(process.cwd(), [{ type: "diff", path: "a.txt", oldText: "before\n", newText: "after\n" }]); + : provider === "cursor" + ? new CursorNativeTurnDiff().push(process.cwd(), [{ type: "diff", path: "a.txt", oldText: "before\n", newText: "after\n" }]) + : openCodeEvidence(openCodeDiffEvent("a.txt", "before", "after")); expect(evidence).toMatchObject({ state: "snapshot", patch }); }); - it.each(["codex", "cursor"])("%s reports a net-zero turn without a partial patch", (provider) => { + it.each(["codex", "cursor", "opencode"])("%s reports a net-zero turn without a partial patch", (provider) => { const cursor = new CursorNativeTurnDiff(); cursor.push(process.cwd(), [{ type: "diff", path: "a.txt", oldText: "before\n", newText: "after\n" }]); + const opencode = new OpenCodeNativeTurnDiff(); + opencode.observe(openCodeDiffEvent("a.txt", "before", "after")); const evidence = provider === "codex" ? nativeTurnDiffEvidence("") - : cursor.push(process.cwd(), [{ type: "diff", path: "a.txt", oldText: "after\n", newText: "before\n" }]); + : provider === "cursor" + ? cursor.push(process.cwd(), [{ type: "diff", path: "a.txt", oldText: "after\n", newText: "before\n" }]) + // Upstream reports a reverted turn as an empty aggregate after a non-empty one. + : opencode.observe([]); expect(evidence).toEqual({ state: "indeterminate-empty" }); }); + + it("opencode treats an empty first event as no information, not evidence", () => { + const diff = new OpenCodeNativeTurnDiff(); + expect(diff.observe([])).toBeNull(); + }); + + it.each(["cursor", "opencode"])("%s rejects unusable native evidence instead of emitting a partial patch", (provider) => { + const evidence = provider === "cursor" + ? new CursorNativeTurnDiff().push(process.cwd(), [{ type: "diff", path: "../outside.txt", oldText: "a\n", newText: "b\n" }]) + : openCodeEvidence([{ file: "../outside.txt", patch: "@@ -1,1 +1,1 @@\n-a\n+b\n", additions: 1, deletions: 1 }]); + expect(evidence).toEqual({ state: "rejected" }); + }); }); diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 75c035dbb..1a4775e74 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -73,3 +73,8 @@ export { isProviderVersionAtLeast, warmCodexProviderVersion, } from "./availability.js"; +export { + OpenCodeNativeTurnDiff, + type OpenCodeFileDiff, + type OpenCodeNativeTurnDiffResult, +} from "./private/opencode/opencode-native-turn-diff.js"; diff --git a/packages/providers/src/private/opencode/__tests__/opencode-native-turn-diff.test.ts b/packages/providers/src/private/opencode/__tests__/opencode-native-turn-diff.test.ts new file mode 100644 index 000000000..1a7218f92 --- /dev/null +++ b/packages/providers/src/private/opencode/__tests__/opencode-native-turn-diff.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { OpenCodeNativeTurnDiff } from "../opencode-native-turn-diff.js"; + +/** + * Build the exact patch shape upstream emits for one file: `Index:`/`====` + * headers, then one full-context hunk covering both complete versions. + * Only single-hunk, full-file replacements are needed for these tests. + */ +function upstreamPatch(file: string, before: string, after: string): string { + const beforeLines = before.length === 0 ? [] : before.replace(/\n$/, "").split("\n"); + const afterLines = after.length === 0 ? [] : after.replace(/\n$/, "").split("\n"); + const body = [ + ...beforeLines.map((line) => `-${line}`), + ...afterLines.map((line) => `+${line}`), + ]; + const beforeRange = beforeLines.length === 0 ? "0,0" : `1,${beforeLines.length}`; + const afterRange = afterLines.length === 0 ? "0,0" : `1,${afterLines.length}`; + return [ + `Index: ${file}`, + "===================================================================", + `--- ${file}`, + `+++ ${file}`, + `@@ -${beforeRange} +${afterRange} @@`, + ...body, + "", + ].join("\n"); +} + +describe("OpenCodeNativeTurnDiff", () => { + it("normalizes one upstream file patch into the canonical aggregate", () => { + const before = "line one\nagent marker: old\nline three\n"; + const after = "line one\nagent marker: new\nline three\n"; + const diff = new OpenCodeNativeTurnDiff(); + const result = diff.observe([{ file: "notes.txt", patch: upstreamPatch("notes.txt", before, after), additions: 1, deletions: 1, status: "modified" }]); + expect(result).toMatchObject({ state: "snapshot" }); + const patch = (result as { patch: string }).patch; + expect(patch).toContain("diff --git a/notes.txt b/notes.txt"); + expect(patch).toContain("-agent marker: old"); + expect(patch).toContain("+agent marker: new"); + expect(patch).toContain(" line one"); + expect(patch).toContain(" line three"); + }); + + it("keeps a same-file external edit out of the native aggregate", () => { + // Upstream computes its diff between its own step snapshots, so a user + // edit that landed between them never enters the evidence. + const agentBefore = "user marker: untouched\nagent marker: old\n"; + const agentAfter = "user marker: untouched\nagent marker: new\n"; + const diff = new OpenCodeNativeTurnDiff(); + const result = diff.observe([{ file: "shared.txt", patch: upstreamPatch("shared.txt", agentBefore, agentAfter), additions: 1, deletions: 1, status: "modified" }]); + const patch = (result as { patch: string }).patch; + expect(patch).not.toContain("user marker: edited externally"); + expect(patch).toContain("-agent marker: old"); + expect(patch).toContain("+agent marker: new"); + }); + + it("reports a reverted turn as indeterminate-empty, not a false empty patch", () => { + const diff = new OpenCodeNativeTurnDiff(); + diff.observe([{ file: "a.txt", patch: upstreamPatch("a.txt", "x\n", "y\n"), additions: 1, deletions: 1, status: "modified" }]); + // Upstream emits an empty diff list once the turn reverts to net-zero; + // the service reconciles it against file effects instead of trusting it. + expect(diff.observe([])).toEqual({ state: "indeterminate-empty" }); + }); + + it("treats an empty first event as no information", () => { + expect(new OpenCodeNativeTurnDiff().observe([])).toBeNull(); + }); + + it("marks file creation and removal explicitly", () => { + const diff = new OpenCodeNativeTurnDiff(); + const created = diff.observe([{ file: "new.txt", patch: upstreamPatch("new.txt", "", "fresh\n"), additions: 1, deletions: 0, status: "added" }]); + expect((created as { patch: string }).patch).toContain("--- /dev/null"); + const removed = new OpenCodeNativeTurnDiff().observe([{ file: "old.txt", patch: upstreamPatch("old.txt", "stale\n", ""), additions: 0, deletions: 1, status: "deleted" }]); + expect((removed as { patch: string }).patch).toContain("+++ /dev/null"); + }); + + it.each([ + ["a missing patch (binary row)", [{ file: "blob.bin", additions: 0, deletions: 0 }]], + ["a non-string patch", [{ file: "a.txt", patch: 42, additions: 1, deletions: 1 }]], + ["an unsafe file name", [{ file: "../escape.txt", patch: upstreamPatch("x", "a\n", "b\n"), additions: 1, deletions: 1 }]], + ["an oversized patch", [{ file: "big.txt", patch: upstreamPatch("big.txt", "x".repeat(2_097_153), "y\n"), additions: 1, deletions: 1 }]], + ["a partial-context patch", [{ file: "a.txt", patch: "--- a/a.txt\n+++ b/a.txt\n@@ -1,3 +1,3 @@\n a\n-b\n+c\n", additions: 1, deletions: 1 }]], + // Upstream's full-context diff always emits one hunk; a second header + // could pass the count check while fabricating duplicated lines. + ["a multi-hunk patch", [{ file: "a.txt", patch: "--- a/a.txt\n+++ b/a.txt\n@@ -1,1 +1,1 @@\n-a\n+b\n@@ -1,1 +1,1 @@\n-a\n+b\n", additions: 2, deletions: 2 }]], + ])("rejects unusable evidence: %s", (_label, entries) => { + expect(new OpenCodeNativeTurnDiff().observe(entries as unknown[])).toEqual({ state: "rejected" }); + }); + + it("stays rejected after bad evidence instead of emitting a partial patch", () => { + const diff = new OpenCodeNativeTurnDiff(); + diff.observe([{ file: "a.txt", patch: upstreamPatch("a.txt", "a\n", "b\n"), additions: 1, deletions: 1 }]); + expect(diff.observe([{ file: "b.txt", additions: 0, deletions: 0 }])).toEqual({ state: "rejected" }); + expect(diff.observe([{ file: "c.txt", patch: upstreamPatch("c.txt", "c\n", "d\n"), additions: 1, deletions: 1 }])).toEqual({ state: "rejected" }); + }); + + it("later events for the same file replace earlier evidence", () => { + const diff = new OpenCodeNativeTurnDiff(); + diff.observe([{ file: "a.txt", patch: upstreamPatch("a.txt", "one\n", "two\n"), additions: 1, deletions: 1 }]); + const result = diff.observe([{ file: "a.txt", patch: upstreamPatch("a.txt", "two\n", "three\n"), additions: 1, deletions: 1 }]); + const patch = (result as { patch: string }).patch; + expect(patch).toContain("+three"); + expect(patch).not.toContain("+two"); + }); +}); diff --git a/packages/providers/src/private/opencode/opencode-native-turn-diff.ts b/packages/providers/src/private/opencode/opencode-native-turn-diff.ts new file mode 100644 index 000000000..029b97b92 --- /dev/null +++ b/packages/providers/src/private/opencode/opencode-native-turn-diff.ts @@ -0,0 +1,244 @@ +import { createTextPatch } from "@mcode/shared"; + +/** Largest accepted native aggregate; matches the server-side turn-diff bound. */ +const MAX_NATIVE_PATCH_BYTES = 2_097_152; +/** Largest retained per-file evidence before the whole aggregate is rejected. */ +const MAX_FILE_EVIDENCE_BYTES = 2_097_152; +/** Largest retained file count before the whole aggregate is rejected. */ +const MAX_TRACKED_FILES = 256; + +/** One upstream `session.diff` file entry (SnapshotFileDiff). */ +export interface OpenCodeFileDiff { + file?: string; + patch?: string; + additions: number; + deletions: number; + status?: "added" | "deleted" | "modified"; +} + +export type OpenCodeNativeTurnDiffResult = + | { state: "snapshot"; patch: string } + | { state: "indeterminate-empty" } + | { state: "rejected" }; + +type FileStatus = "added" | "deleted" | "modified"; + +interface ValidatedEntry { + file: string; + patch: string; + status: FileStatus; +} + +/** + * Builds one complete native patch from upstream `session.diff` event entries. + * Upstream diffs are computed from its own step-start/step-finish snapshots, so + * they carry agent-attributed evidence; a same-file user edit made outside the + * agent never appears in them. + */ +export class OpenCodeNativeTurnDiff { + private readonly files = new Map(); + private contentBytes = 0; + private rejected = false; + + /** + * Fold one `session.diff` event into the aggregate. Returns null when the + * event carries no information (an empty list before any evidence). An + * empty list after evidence means upstream reverted to net-zero, which is + * indeterminate-empty: the service reconciles it against file effects. + */ + observe(entries: readonly unknown[]): OpenCodeNativeTurnDiffResult | null { + if (this.rejected) return { state: "rejected" }; + if (!Array.isArray(entries)) return this.reject(); + if (entries.length === 0) return this.observeEmpty(); + for (const entry of entries) { + if (!this.addEntry(entry)) return this.reject(); + } + return this.aggregate(); + } + + private observeEmpty(): OpenCodeNativeTurnDiffResult | null { + if (this.files.size === 0) return null; + this.files.clear(); + this.contentBytes = 0; + return { state: "indeterminate-empty" }; + } + + private addEntry(raw: unknown): boolean { + const entry = validateEntry(raw); + if (!entry) return false; + const previous = this.files.get(entry.file); + if (!previous && this.files.size >= MAX_TRACKED_FILES) return false; + const nextBytes = this.contentBytes - (previous ? Buffer.byteLength(previous.patch) : 0) + Buffer.byteLength(entry.patch); + if (nextBytes > MAX_NATIVE_PATCH_BYTES) return false; + this.files.set(entry.file, { patch: entry.patch, status: entry.status }); + this.contentBytes = nextBytes; + return true; + } + + private aggregate(): OpenCodeNativeTurnDiffResult | null { + const patches: string[] = []; + for (const [file, { patch, status }] of this.files) { + const normalized = normalizeUpstreamPatch(file, patch, status); + if (normalized === undefined) return this.reject(); + if (normalized !== null) patches.push(normalized); + } + if (patches.length === 0) return { state: "indeterminate-empty" }; + const patch = patches.join(""); + return Buffer.byteLength(patch) <= MAX_NATIVE_PATCH_BYTES ? { state: "snapshot", patch } : this.reject(); + } + + private reject(): OpenCodeNativeTurnDiffResult { + this.rejected = true; + this.files.clear(); + this.contentBytes = 0; + return { state: "rejected" }; + } +} + +/** Validate one untrusted upstream entry; binary rows (no patch) cannot become a text comparison. */ +function validateEntry(raw: unknown): ValidatedEntry | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const entry = raw as Record; + if (typeof entry.file !== "string" || !isSafeRelativePath(entry.file)) return null; + if (typeof entry.patch !== "string" || Buffer.byteLength(entry.patch) > MAX_FILE_EVIDENCE_BYTES) return null; + const status = parseStatus(entry.status); + if (status === null) return null; + return { file: entry.file, patch: entry.patch, status }; +} + +function parseStatus(value: unknown): FileStatus | null { + if (value === undefined) return "modified"; + return value === "added" || value === "deleted" || value === "modified" ? value : null; +} + +function isSafeRelativePath(file: string): boolean { + if (file.length === 0 || file.length > 4096 || /[\x00-\x1f"\\]/.test(file)) return false; + return file.split("/").every((part) => part !== "" && part !== "." && part !== ".."); +} + +/** + * Convert one upstream per-file patch into Mcode's validated `diff --git` shape. + * Upstream emits `formatPatch(structuredPatch(...))` output: `Index:`/`====` headers, + * absolute paths, and full-file context. Rebuilding from the parsed before/after + * content keeps one canonical shape for every provider. Returns null for a net-zero + * file and undefined when the patch cannot be trusted. + */ +function normalizeUpstreamPatch( + file: string, + patch: string, + status: FileStatus, +): string | null | undefined { + const full = parseFullFilePatch(patch); + if (!full) return undefined; + const kind = status === "added" ? "added" : status === "deleted" ? "removed" : "edited"; + return createTextPatch(file, full.before, full.after, kind); +} + +interface FullFileContent { + before: string; + after: string; +} + +interface PatchParseState { + before: string[]; + after: string[]; + beforeNewline: boolean; + afterNewline: boolean; + expectedBefore: number; + expectedAfter: number; + sawHunk: boolean; + inHunks: boolean; + previous: "both" | "before" | "after" | undefined; +} + +/** + * Reconstruct before/after content from a full-context unified patch. + * Upstream uses `context: Number.MAX_SAFE_INTEGER`, so every line of both + * versions is present; anything else is rejected rather than trusted. + */ +function parseFullFilePatch(patch: string): FullFileContent | undefined { + const lines = patch.split("\n"); + // A patch ending in a newline splits into a trailing empty element. + if (lines.at(-1) === "") lines.pop(); + const state: PatchParseState = { + before: [], after: [], beforeNewline: true, afterNewline: true, + expectedBefore: 0, expectedAfter: 0, sawHunk: false, inHunks: false, previous: undefined, + }; + for (const line of lines) { + if (!consumePatchLine(state, line)) return undefined; + } + if (!state.sawHunk) return undefined; + if (state.before.length !== state.expectedBefore || state.after.length !== state.expectedAfter) return undefined; + return { + before: joinLines(state.before, state.beforeNewline), + after: joinLines(state.after, state.afterNewline), + }; +} + +function consumePatchLine(state: PatchParseState, line: string): boolean { + if (line.startsWith("@@ ")) return consumeHunkHeader(state, line); + if (!state.inHunks) return true; + if (line === "\\ No newline at end of file") return consumeNewlineMarker(state); + return consumeContentLine(state, line); +} + +function consumeHunkHeader(state: PatchParseState, line: string): boolean { + // Upstream's full-context diff always yields exactly one hunk; a second + // header means malformed evidence, not another range to merge. + if (state.sawHunk) return false; + const range = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line); + if (!range) return false; + // Only a hunk covering the whole file from line 1 yields complete + // before/after content; a partial patch would silently truncate. + if (!coversWholeFile(range[1]!, range[2]) || !coversWholeFile(range[3]!, range[4])) return false; + state.expectedBefore += range[2] === undefined ? 1 : Number(range[2]); + state.expectedAfter += range[4] === undefined ? 1 : Number(range[4]); + state.inHunks = true; + state.sawHunk = true; + state.previous = undefined; + return true; +} + +function coversWholeFile(start: string, count: string | undefined): boolean { + return start === "1" || (start === "0" && count === "0"); +} + +function consumeNewlineMarker(state: PatchParseState): boolean { + if (state.previous === "both") { + state.beforeNewline = false; + state.afterNewline = false; + } else if (state.previous === "before") { + state.beforeNewline = false; + } else if (state.previous === "after") { + state.afterNewline = false; + } else { + return false; + } + return true; +} + +function consumeContentLine(state: PatchParseState, line: string): boolean { + const prefix = line[0]; + const text = line.slice(1); + if (prefix === " ") { + state.before.push(text); + state.after.push(text); + state.previous = "both"; + } else if (prefix === "-") { + state.before.push(text); + state.previous = "before"; + } else if (prefix === "+") { + state.after.push(text); + state.previous = "after"; + } else { + return false; + } + return true; +} + +/** Rebuild exact file text from patch lines plus the trailing-newline marker. */ +function joinLines(lines: readonly string[], trailingNewline: boolean): string { + if (lines.length === 0) return ""; + const joined = lines.join("\n"); + return trailingNewline ? `${joined}\n` : joined; +} From 5730de79d0d797d5b10c671e3afa89f36855c0dd Mon Sep 17 00:00:00 2001 From: Agent Runtime Fixture Date: Mon, 28 Sep 2026 02:07:14 +0100 Subject: [PATCH 2/2] fix(verify-mcode): accept git-root-relative paths and broadcast pushes in watcher proof --- .../scripts/provider-completeness.mjs | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/.agents/skills/verify-mcode/scripts/provider-completeness.mjs b/.agents/skills/verify-mcode/scripts/provider-completeness.mjs index 776b81189..86e9d3fb1 100644 --- a/.agents/skills/verify-mcode/scripts/provider-completeness.mjs +++ b/.agents/skills/verify-mcode/scripts/provider-completeness.mjs @@ -348,31 +348,47 @@ export async function runWorkspaceInvalidationJourney({ repoRoot, workspace, run await observer.rpc("file.refresh", { workspaceId }); recordOwnedFile(run.run, ownerFile); recordOwnedFile(run.run, observerFile); + const ownerPath = expectedChangePath(ownerFile); + const observerPath = expectedChangePath(observerFile); await io.writeFile(ownerFile, "WATCHER_OWNER_MARKER\n", "utf8"); await owner.rpc("file.refresh", { workspaceId }); await Promise.all([ - waitForExactWorkspaceInvalidation(ownerEvents, workspaceId, NodePath.basename(ownerFile), timeoutMs), - waitForExactWorkspaceInvalidation(observerEvents, workspaceId, NodePath.basename(ownerFile), timeoutMs), + waitForExactWorkspaceInvalidation(ownerEvents, workspaceId, ownerPath, timeoutMs), + waitForExactWorkspaceInvalidation(observerEvents, workspaceId, ownerPath, timeoutMs), ]); await owner.close(); const ownerEventCount = ownerEvents.length; const observerStart = observerEvents.length; await io.appendFile(observerFile, "WATCHER_OBSERVER_MARKER\n", "utf8"); await observer.rpc("file.refresh", { workspaceId }); - await waitForExactWorkspaceInvalidation(observerEvents, workspaceId, NodePath.basename(observerFile), timeoutMs, observerStart); + await waitForExactWorkspaceInvalidation(observerEvents, workspaceId, observerPath, timeoutMs, observerStart); if (ownerEvents.length !== ownerEventCount) throw new Error("Condition: disconnected client received a later files.changed push."); return { kind: "live-rpc-proof", control: "public file.refresh RPC and files.changed push", workspaceId, - owner: { closed: true, changes: [NodePath.basename(ownerFile)] }, - observer: { active: true, changes: [NodePath.basename(ownerFile), NodePath.basename(observerFile)] }, + owner: { closed: true, changes: [ownerPath] }, + observer: { active: true, changes: [ownerPath, observerPath] }, }; } finally { await Promise.all([owner.close(), observer.close()]); } } +/** `files.changed` reports git-root-relative paths; a fixture inside a parent repo carries its directory prefix. */ +function expectedChangePath(file) { + try { + const prefix = NodeChildProcess.execFileSync( + "git", + ["-C", NodePath.dirname(file), "rev-parse", "--show-prefix"], + { encoding: "utf8", timeout: 10_000 }, + ).trim(); + return prefix + NodePath.basename(file); + } catch { + return NodePath.basename(file); + } +} + function watcherFixtureFiles(run) { if (!run?.run || typeof run.fixtureDirectory !== "string") throw new Error("Condition: watcher proof requires one owned workspace and fixture directory."); const ownerFile = NodePath.join(run.fixtureDirectory, "watch-owner-sentinel.txt"); @@ -405,8 +421,10 @@ function recordOwnedFile(run, file) { async function waitForExactWorkspaceInvalidation(events, workspaceId, path, timeoutMs, start = 0) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { + // files.changed is a global broadcast; unrelated workspaces and scopes + // legitimately push during the wait. Only the absence of the exact owned + // push (timeout) proves a defect. const changes = events.slice(start).filter(isFilesChangedPush); - if (changes.some((event) => !isExactWorkspaceInvalidation(event, workspaceId, path))) throw new Error(`Condition: files.changed push did not match the owned ${path} watcher scope.`); if (changes.some((event) => isExactWorkspaceInvalidation(event, workspaceId, path))) return; await delay(25); }