From ab44a2e8d4c963210adc9f11a7ddb1e3ca617c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20M=C3=A2rza?= Date: Sun, 22 Mar 2026 02:11:37 +0200 Subject: [PATCH] =?UTF-8?q?test:=20Phase=205=20=E2=80=94=2024=20tests=20fo?= =?UTF-8?q?r=20filterEdited,=20filterEphemeral,=20ContextEdit=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filterEdited tests (8): - No-edit passthrough, hidden removal, superseded removal, hidden=false kept - Mixed visibility filtering, synthetic placeholder on all-hidden - Message alternation preservation, identity optimization filterEphemeral tests (6): - No-ephemeral passthrough, all-ephemeral removal, partial-ephemeral kept - Paired assistant removal via parentID, user kept when only assistant ephemeral - Multiple ephemeral messages filtered ContextEdit validation tests (10): - Ownership: privileged bypass, user protection, cross-agent block, self-edit - Budget: under 70% allowed, over 70% blocked - Recency: last 2 turns protected, older messages editable - Tool protection: skill tool parts blocked - General: successful hide returns CAS hash 1512 tests passing (up from 1488), 0 tsgo errors. --- WHAT_WE_DID.md | 1 + .../test/context-edit/validation.test.ts | 404 ++++++++++++++++++ .../test/session/filter-edited.test.ts | 123 ++++++ .../test/session/filter-ephemeral.test.ts | 101 +++++ 4 files changed, 629 insertions(+) create mode 100644 packages/opencode/test/context-edit/validation.test.ts create mode 100644 packages/opencode/test/session/filter-edited.test.ts create mode 100644 packages/opencode/test/session/filter-ephemeral.test.ts diff --git a/WHAT_WE_DID.md b/WHAT_WE_DID.md index a75374a26..1d0eb0040 100644 --- a/WHAT_WE_DID.md +++ b/WHAT_WE_DID.md @@ -18,3 +18,4 @@ CAS, edit graph, context editing (6 ops), side threads, objective tracker, class - **#26:** Phase 1 security fixes: S1 symlink bypass, S2 exec→spawn, S4 server auth, S5 sensitive deny-list, S3 warning (13 tests) - **#27:** Phase 2 upstream fixes: prompt parts (#17815), thinkingConfig guard (#18283), chunk timeout (#18264), error messages (#18165), event queue (#18259) - **#28:** Phase 3+4 merged: OpenTUI 0.1.88 upgrade, agent ordering stability. Most other items already applied or diverged. +- **#29:** Phase 5 tests: filterEdited (8), filterEphemeral (6), ContextEdit validation (10) — 24 new tests diff --git a/packages/opencode/test/context-edit/validation.test.ts b/packages/opencode/test/context-edit/validation.test.ts new file mode 100644 index 000000000..622d0e4a6 --- /dev/null +++ b/packages/opencode/test/context-edit/validation.test.ts @@ -0,0 +1,404 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { ContextEdit } from "../../src/context-edit" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionID, MessageID, PartID } from "../../src/session/schema" +import { Instance } from "../fixture/instance-shim" + +const projectRoot = path.join(__dirname, "../..") + +// ── Helpers ─────────────────────────────────────────────── + +async function createAssistantMessage( + sessionID: SessionID, + agent: string, + opts?: { time?: number }, +): Promise<{ messageID: MessageID; partID: PartID }> { + const messageID = MessageID.ascending() + await Session.updateMessage({ + id: messageID, + sessionID, + role: "assistant", + time: { created: opts?.time ?? Date.now() }, + parentID: "msg_0", + modelID: "test", + providerID: "test", + mode: "", + agent, + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as unknown as MessageV2.Info) + + const partID = PartID.ascending() + await Session.updatePart({ + id: partID, + sessionID, + messageID, + type: "text" as const, + text: "assistant output", + } as MessageV2.TextPart) + + return { messageID, partID } +} + +async function createUserMessage( + sessionID: SessionID, + opts?: { time?: number }, +): Promise<{ messageID: MessageID; partID: PartID }> { + const messageID = MessageID.ascending() + await Session.updateMessage({ + id: messageID, + sessionID, + role: "user", + time: { created: opts?.time ?? Date.now() }, + agent: "user", + model: { providerID: "test", modelID: "test" }, + tools: {}, + mode: "", + } as unknown as MessageV2.Info) + + const partID = PartID.ascending() + await Session.updatePart({ + id: partID, + sessionID, + messageID, + type: "text" as const, + text: "user input", + } as MessageV2.TextPart) + + return { messageID, partID } +} + +async function createToolPart( + sessionID: SessionID, + messageID: MessageID, + toolName: string, +): Promise { + const partID = PartID.ascending() + await Session.updatePart({ + id: partID, + sessionID, + messageID, + type: "tool" as const, + callID: `call_${partID}`, + tool: toolName, + state: { + status: "completed" as const, + input: {}, + output: "tool output", + title: toolName, + time: { start: Date.now(), end: Date.now() }, + metadata: {}, + }, + } as MessageV2.ToolPart) + return partID +} + +/** Create N extra text parts on an existing message. Returns all part IDs created. */ +async function addTextParts( + sessionID: SessionID, + messageID: MessageID, + count: number, + opts?: { hidden?: boolean }, +): Promise { + const ids: PartID[] = [] + for (let i = 0; i < count; i++) { + const partID = PartID.ascending() + await Session.updatePart({ + id: partID, + sessionID, + messageID, + type: "text" as const, + text: `extra part ${i}`, + ...(opts?.hidden + ? { + edit: { + hidden: true, + casHash: `fakecas_${i}`, + editedAt: Date.now(), + editedBy: "test", + version: `v_${i}`, + }, + } + : {}), + } as MessageV2.TextPart) + ids.push(partID) + } + return ids +} + +/** + * Build a conversation with enough messages so that early messages + * are outside the protected recent-turns window (last 2 turns = 4 messages). + * Returns the first assistant message's IDs (which is old enough to edit) + * and the last assistant message's IDs (which is protected). + */ +async function buildConversation(sessionID: SessionID) { + // Turn 1 (old) + const oldUser = await createUserMessage(sessionID) + const oldAssistant = await createAssistantMessage(sessionID, "build") + // Turn 2 + await createUserMessage(sessionID) + await createAssistantMessage(sessionID, "build") + // Turn 3 (recent — protected) + await createUserMessage(sessionID) + const recentAssistant = await createAssistantMessage(sessionID, "build") + + return { oldAssistant, recentAssistant, oldUser } +} + +// ── Ownership tests ─────────────────────────────────────── + +describe("ContextEdit.hide — ownership validation", () => { + test("privileged agent (focus) can hide assistant message from another agent", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const { oldAssistant } = await buildConversation(session.id) + + const result = await ContextEdit.hide({ + sessionID: session.id, + partID: oldAssistant.partID, + messageID: oldAssistant.messageID, + agent: "focus", + }) + + expect(result.success).toBe(true) + await Session.remove(session.id) + }, + }) + }) + + test("regular agent cannot hide user messages", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const { oldUser } = await buildConversation(session.id) + + const result = await ContextEdit.hide({ + sessionID: session.id, + partID: oldUser.partID, + messageID: oldUser.messageID, + agent: "build", + }) + + expect(result.success).toBe(false) + expect(result.error).toBe("Cannot edit user messages") + await Session.remove(session.id) + }, + }) + }) + + test("regular agent cannot hide another agent's messages", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const { oldAssistant } = await buildConversation(session.id) + + const result = await ContextEdit.hide({ + sessionID: session.id, + partID: oldAssistant.partID, + messageID: oldAssistant.messageID, + agent: "plan", // message belongs to "build" + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("Cannot edit messages from agent") + await Session.remove(session.id) + }, + }) + }) + + test("regular agent can hide own messages", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const { oldAssistant } = await buildConversation(session.id) + + const result = await ContextEdit.hide({ + sessionID: session.id, + partID: oldAssistant.partID, + messageID: oldAssistant.messageID, + agent: "build", // same agent as message + }) + + expect(result.success).toBe(true) + await Session.remove(session.id) + }, + }) + }) +}) + +// ── Budget tests ────────────────────────────────────────── + +describe("ContextEdit.hide — budget validation", () => { + test("allows hiding when under 70% ratio", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + // Build conversation: 6 messages = 6 parts by default + const { oldAssistant } = await buildConversation(session.id) + + // Add extra parts to the old message so we have plenty of headroom + await addTextParts(session.id, oldAssistant.messageID, 4) + // Now: 10 total parts across all messages, 0 hidden + // Hiding 1 → 1/10 = 10% which is under 70% + + const result = await ContextEdit.hide({ + sessionID: session.id, + partID: oldAssistant.partID, + messageID: oldAssistant.messageID, + agent: "build", + }) + + expect(result.success).toBe(true) + await Session.remove(session.id) + }, + }) + }) + + test("blocks hiding when at 70% ratio", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const { oldAssistant } = await buildConversation(session.id) + + // Conversation has 6 parts (one per message). Add 4 more to old message → 10 total. + // Mark 6 as hidden → already at 60%. Hiding one more → 7/10 = 70% → blocked (> 70% check is strict >). + await addTextParts(session.id, oldAssistant.messageID, 4, { hidden: true }) + // 6 original + 4 hidden = 10 total, 4 hidden. + // Add 2 more hidden parts on user messages to push hidden count up + const msgs = await Session.messages({ sessionID: session.id }) + const userMsg = msgs.find((m) => m.info.role === "user")! + await addTextParts(session.id, userMsg.info.id as MessageID, 2, { hidden: true }) + // Now: 12 total, 6 hidden. Hiding 1 more → (6+1)/12 = 7/12 ≈ 58% → still under. + // We need more hidden. Let's add more hidden parts. + await addTextParts(session.id, userMsg.info.id as MessageID, 3, { hidden: true }) + // Now: 15 total, 9 hidden. Hiding 1 more → (9+1)/15 = 66% → still under. + // Add more hidden: + await addTextParts(session.id, userMsg.info.id as MessageID, 5, { hidden: true }) + // Now: 20 total, 14 hidden. Hiding 1 more → (14+1)/20 = 75% → blocked! + + const result = await ContextEdit.hide({ + sessionID: session.id, + partID: oldAssistant.partID, + messageID: oldAssistant.messageID, + agent: "build", + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("Cannot hide more than 70%") + await Session.remove(session.id) + }, + }) + }) +}) + +// ── Recency tests ───────────────────────────────────────── + +describe("ContextEdit.hide — recency validation", () => { + test("blocks hiding recent messages (last 2 turns)", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const { recentAssistant } = await buildConversation(session.id) + + const result = await ContextEdit.hide({ + sessionID: session.id, + partID: recentAssistant.partID, + messageID: recentAssistant.messageID, + agent: "build", + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("recent messages") + await Session.remove(session.id) + }, + }) + }) + + test("allows hiding older messages", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const { oldAssistant } = await buildConversation(session.id) + + const result = await ContextEdit.hide({ + sessionID: session.id, + partID: oldAssistant.partID, + messageID: oldAssistant.messageID, + agent: "build", + }) + + expect(result.success).toBe(true) + await Session.remove(session.id) + }, + }) + }) +}) + +// ── Tool protection ─────────────────────────────────────── + +describe("ContextEdit.hide — tool protection", () => { + test("blocks hiding skill tool results", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const { oldAssistant } = await buildConversation(session.id) + + // Add a skill tool part to the old assistant message + const toolPartID = await createToolPart(session.id, oldAssistant.messageID, "skill") + + const result = await ContextEdit.hide({ + sessionID: session.id, + partID: toolPartID, + messageID: oldAssistant.messageID, + agent: "build", + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("Cannot hide protected tool") + expect(result.error).toContain("skill") + await Session.remove(session.id) + }, + }) + }) +}) + +// ── General ─────────────────────────────────────────────── + +describe("ContextEdit.hide — general", () => { + test("hide returns success with CAS hash", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const { oldAssistant } = await buildConversation(session.id) + + const result = await ContextEdit.hide({ + sessionID: session.id, + partID: oldAssistant.partID, + messageID: oldAssistant.messageID, + agent: "build", + }) + + expect(result.success).toBe(true) + expect(result.casHash).toBeDefined() + expect(typeof result.casHash).toBe("string") + expect(result.casHash!.length).toBeGreaterThan(0) + await Session.remove(session.id) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/filter-edited.test.ts b/packages/opencode/test/session/filter-edited.test.ts new file mode 100644 index 000000000..c7206ce4e --- /dev/null +++ b/packages/opencode/test/session/filter-edited.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionID, MessageID, PartID } from "../../src/session/schema" + +const sid = SessionID.make("ses_test") + +function makePart(msgId: string, overrides: Partial = {}): MessageV2.TextPart { + return { + id: PartID.ascending(), + sessionID: sid, + messageID: MessageID.make(msgId), + type: "text", + text: "content", + ...overrides, + } as MessageV2.TextPart +} + +function makeMsg(id: string, role: "user" | "assistant", parts: MessageV2.Part[]): MessageV2.WithParts { + return { + info: { id: MessageID.make(id), sessionID: sid, role, time: { created: Date.now() } } as MessageV2.Info, + parts, + } +} + +describe("filterEdited", () => { + test("returns unchanged when no edits", () => { + const msgs = [makeMsg("m1", "user", [makePart("m1")])] + const result = MessageV2.filterEdited(msgs) + expect(result).toBe(msgs) + }) + + test("removes hidden parts", () => { + const msgs = [ + makeMsg("m1", "user", [ + makePart("m1", { edit: { hidden: true, editedAt: Date.now(), editedBy: "build" } }), + makePart("m1", { text: "visible" }), + ]), + ] + const result = MessageV2.filterEdited(msgs) + expect(result.length).toBe(1) + expect(result[0].parts.length).toBe(1) + expect((result[0].parts[0] as MessageV2.TextPart).text).toBe("visible") + }) + + test("removes superseded parts", () => { + const msgs = [ + makeMsg("m1", "user", [ + makePart("m1", { + edit: { hidden: false, supersededBy: "prt_other", editedAt: Date.now(), editedBy: "build" }, + }), + makePart("m1", { text: "replacement" }), + ]), + ] + const result = MessageV2.filterEdited(msgs) + expect(result.length).toBe(1) + expect(result[0].parts.length).toBe(1) + expect((result[0].parts[0] as MessageV2.TextPart).text).toBe("replacement") + }) + + test("keeps parts with edit.hidden=false", () => { + const msgs = [ + makeMsg("m1", "user", [ + makePart("m1", { edit: { hidden: false, editedAt: Date.now(), editedBy: "build" } }), + ]), + ] + const result = MessageV2.filterEdited(msgs) + expect(result.length).toBe(1) + expect(result[0].parts.length).toBe(1) + }) + + test("mixed hidden/visible returns only visible", () => { + const msgs = [ + makeMsg("m1", "user", [ + makePart("m1", { text: "a" }), + makePart("m1", { text: "b", edit: { hidden: true, editedAt: Date.now(), editedBy: "build" } }), + makePart("m1", { text: "c" }), + ]), + ] + const result = MessageV2.filterEdited(msgs) + expect(result[0].parts.length).toBe(2) + expect((result[0].parts[0] as MessageV2.TextPart).text).toBe("a") + expect((result[0].parts[1] as MessageV2.TextPart).text).toBe("c") + }) + + test("all parts hidden creates synthetic placeholder", () => { + const msgs = [ + makeMsg("m1", "user", [ + makePart("m1", { edit: { hidden: true, editedAt: Date.now(), editedBy: "build" } }), + makePart("m1", { edit: { hidden: true, editedAt: Date.now(), editedBy: "build" } }), + ]), + ] + const result = MessageV2.filterEdited(msgs) + expect(result.length).toBe(1) + expect(result[0].parts.length).toBe(1) + const part = result[0].parts[0] as MessageV2.TextPart + expect(part.text).toBe("[Content edited out]") + expect(part.synthetic).toBe(true) + }) + + test("preserves message alternation with placeholder", () => { + const msgs = [ + makeMsg("m1", "user", [ + makePart("m1", { edit: { hidden: true, editedAt: Date.now(), editedBy: "build" } }), + ]), + makeMsg("m2", "assistant", [makePart("m2", { text: "response" })]), + ] + const result = MessageV2.filterEdited(msgs) + expect(result.length).toBe(2) + expect(result[0].info.role).toBe("user") + expect((result[0].parts[0] as MessageV2.TextPart).text).toBe("[Content edited out]") + expect(result[1].info.role).toBe("assistant") + expect((result[1].parts[0] as MessageV2.TextPart).text).toBe("response") + }) + + test("identity preserved when no filtering needed", () => { + const part = makePart("m1", { edit: { hidden: false, editedAt: Date.now(), editedBy: "build" } }) + const msgs = [makeMsg("m1", "user", [part])] + const result = MessageV2.filterEdited(msgs) + // hasEdits is true so we enter filtering, but no parts are removed + // The filter returns the same parts array since nothing is filtered + expect(result[0].parts[0]).toBe(part) + }) +}) diff --git a/packages/opencode/test/session/filter-ephemeral.test.ts b/packages/opencode/test/session/filter-ephemeral.test.ts new file mode 100644 index 000000000..cc4c6dc47 --- /dev/null +++ b/packages/opencode/test/session/filter-ephemeral.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionID, MessageID, PartID } from "../../src/session/schema" + +const sid = SessionID.make("ses_test") + +function makePart(msgId: string, overrides: Partial = {}): MessageV2.TextPart { + return { + id: PartID.ascending(), + sessionID: sid, + messageID: MessageID.make(msgId), + type: "text", + text: "content", + ...overrides, + } as MessageV2.TextPart +} + +function makeUserMsg(id: string, parts: MessageV2.Part[]): MessageV2.WithParts { + return { + info: { id: MessageID.make(id), sessionID: sid, role: "user", time: { created: Date.now() } } as MessageV2.Info, + parts, + } +} + +function makeAssistantMsg(id: string, parentId: string, parts: MessageV2.Part[]): MessageV2.WithParts { + return { + info: { + id: MessageID.make(id), + sessionID: sid, + role: "assistant", + parentID: MessageID.make(parentId), + time: { created: Date.now() }, + } as MessageV2.Info, + parts, + } +} + +const ephemeralLifecycle = { hint: "ephemeral" as const, setAt: Date.now(), setBy: "build", turnWhenSet: 1 } + +describe("filterEphemeral", () => { + test("returns unchanged when no ephemeral parts", () => { + const msgs = [makeUserMsg("m1", [makePart("m1")])] + const result = MessageV2.filterEphemeral(msgs) + expect(result).toBe(msgs) + }) + + test("removes message where all parts are ephemeral", () => { + const msgs = [ + makeUserMsg("m1", [ + makePart("m1", { lifecycle: ephemeralLifecycle }), + makePart("m1", { lifecycle: ephemeralLifecycle }), + ]), + ] + const result = MessageV2.filterEphemeral(msgs) + expect(result.length).toBe(0) + }) + + test("keeps message with partial ephemeral parts", () => { + const msgs = [ + makeUserMsg("m1", [ + makePart("m1", { lifecycle: ephemeralLifecycle }), + makePart("m1", { text: "keep me" }), + ]), + ] + const result = MessageV2.filterEphemeral(msgs) + expect(result.length).toBe(1) + expect(result[0].parts.length).toBe(2) + }) + + test("removes paired assistant response", () => { + const msgs = [ + makeUserMsg("u1", [makePart("u1", { lifecycle: ephemeralLifecycle })]), + makeAssistantMsg("a1", "u1", [makePart("a1", { text: "response" })]), + ] + const result = MessageV2.filterEphemeral(msgs) + expect(result.length).toBe(0) + }) + + test("keeps user when only assistant is ephemeral", () => { + const msgs = [ + makeUserMsg("u1", [makePart("u1", { text: "question" })]), + makeAssistantMsg("a1", "u1", [makePart("a1", { lifecycle: ephemeralLifecycle })]), + ] + const result = MessageV2.filterEphemeral(msgs) + expect(result.length).toBe(1) + expect(result[0].info.role).toBe("user") + }) + + test("handles multiple ephemeral messages", () => { + const msgs = [ + makeUserMsg("u1", [makePart("u1", { lifecycle: ephemeralLifecycle })]), + makeUserMsg("u2", [makePart("u2", { text: "keep" })]), + makeUserMsg("u3", [makePart("u3", { lifecycle: ephemeralLifecycle })]), + makeUserMsg("u4", [makePart("u4", { text: "also keep" })]), + ] + const result = MessageV2.filterEphemeral(msgs) + expect(result.length).toBe(2) + expect(result[0].info.id).toBe(MessageID.make("u2")) + expect(result[1].info.id).toBe(MessageID.make("u4")) + }) +})