diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index e0bf94a950..2d12563c6a 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -4,6 +4,201 @@ import { InstructionPrompt } from "../../src/session/instruction" import { Instance } from "../../src/project/instance" import { Global } from "../../src/global" import { tmpdir } from "../fixture/fixture" +import type { MessageV2 } from "../../src/session/message-v2" +import { SessionID, MessageID, PartID } from "../../src/session/schema" + +// ─── Helpers for InstructionPrompt.loaded() ───────────────────────────────── + +const sid = SessionID.make("test-session") + +function makeUserMsg(id: string, parts: MessageV2.Part[]): MessageV2.WithParts { + return { + info: { + id: MessageID.make(id), + sessionID: sid, + role: "user" as const, + time: { created: 0 }, + agent: "user", + model: { providerID: "test" as any, modelID: "test" as any }, + tools: {}, + mode: "", + } as MessageV2.User, + parts, + } +} + +function readToolPart(opts: { + id: string + messageID: string + status: "completed" | "running" | "error" + loaded?: unknown[] + compacted?: number +}): MessageV2.ToolPart { + if (opts.status === "completed") { + return { + id: PartID.make(opts.id), + sessionID: sid, + messageID: MessageID.make(opts.messageID), + type: "tool", + callID: opts.id, + tool: "read", + state: { + status: "completed", + input: {}, + output: "file content", + title: "Read file", + metadata: opts.loaded !== undefined ? { loaded: opts.loaded } : {}, + time: { start: 0, end: 1, ...(opts.compacted !== undefined ? { compacted: opts.compacted } : {}) }, + }, + } as MessageV2.ToolPart + } + if (opts.status === "running") { + return { + id: PartID.make(opts.id), + sessionID: sid, + messageID: MessageID.make(opts.messageID), + type: "tool", + callID: opts.id, + tool: "read", + state: { + status: "running", + input: {}, + time: { start: 0 }, + }, + } as MessageV2.ToolPart + } + return { + id: PartID.make(opts.id), + sessionID: sid, + messageID: MessageID.make(opts.messageID), + type: "tool", + callID: opts.id, + tool: "read", + state: { + status: "error", + input: {}, + error: "read failed", + time: { start: 0, end: 1 }, + }, + } as MessageV2.ToolPart +} + +function nonReadToolPart(opts: { + id: string + messageID: string + tool: string + loaded?: unknown[] +}): MessageV2.ToolPart { + return { + id: PartID.make(opts.id), + sessionID: sid, + messageID: MessageID.make(opts.messageID), + type: "tool", + callID: opts.id, + tool: opts.tool, + state: { + status: "completed", + input: {}, + output: "done", + title: "Tool done", + metadata: opts.loaded !== undefined ? { loaded: opts.loaded } : {}, + time: { start: 0, end: 1 }, + }, + } as MessageV2.ToolPart +} + +// ─── InstructionPrompt.loaded() ───────────────────────────────────────────── + +describe("InstructionPrompt.loaded", () => { + test("returns empty set for messages with no tool parts", () => { + const textPart: MessageV2.Part = { + id: PartID.make("p1"), + sessionID: sid, + messageID: MessageID.make("m1"), + type: "text", + content: "hello", + } as MessageV2.Part + const result = InstructionPrompt.loaded([makeUserMsg("m1", [textPart])]) + expect(result.size).toBe(0) + }) + + test("extracts paths from completed read tool parts with loaded metadata", () => { + const part = readToolPart({ + id: "p1", + messageID: "m1", + status: "completed", + loaded: ["/project/subdir/AGENTS.md", "/project/lib/AGENTS.md"], + }) + const result = InstructionPrompt.loaded([makeUserMsg("m1", [part])]) + expect(result.size).toBe(2) + expect(result.has("/project/subdir/AGENTS.md")).toBe(true) + expect(result.has("/project/lib/AGENTS.md")).toBe(true) + }) + + test("skips compacted tool parts", () => { + const part = readToolPart({ + id: "p1", + messageID: "m1", + status: "completed", + loaded: ["/project/AGENTS.md"], + compacted: 12345, + }) + const result = InstructionPrompt.loaded([makeUserMsg("m1", [part])]) + expect(result.size).toBe(0) + }) + + test("skips non-read tool parts even with loaded metadata", () => { + const part = nonReadToolPart({ + id: "p1", + messageID: "m1", + tool: "bash", + loaded: ["/project/AGENTS.md"], + }) + const result = InstructionPrompt.loaded([makeUserMsg("m1", [part])]) + expect(result.size).toBe(0) + }) + + test("skips non-completed read tool parts", () => { + const runningPart = readToolPart({ id: "p1", messageID: "m1", status: "running" }) + const errorPart = readToolPart({ id: "p2", messageID: "m1", status: "error" }) + const result = InstructionPrompt.loaded([makeUserMsg("m1", [runningPart, errorPart])]) + expect(result.size).toBe(0) + }) + + test("filters out non-string entries in the loaded array", () => { + const part = readToolPart({ + id: "p1", + messageID: "m1", + status: "completed", + loaded: ["/valid/path", 42, null, { nested: true }, "/another/path", undefined], + }) + const result = InstructionPrompt.loaded([makeUserMsg("m1", [part])]) + expect(result.size).toBe(2) + expect(result.has("/valid/path")).toBe(true) + expect(result.has("/another/path")).toBe(true) + }) + + test("deduplicates paths across multiple messages", () => { + const part1 = readToolPart({ + id: "p1", + messageID: "m1", + status: "completed", + loaded: ["/project/AGENTS.md"], + }) + const part2 = readToolPart({ + id: "p2", + messageID: "m2", + status: "completed", + loaded: ["/project/AGENTS.md", "/project/lib/AGENTS.md"], + }) + const result = InstructionPrompt.loaded([makeUserMsg("m1", [part1]), makeUserMsg("m2", [part2])]) + expect(result.size).toBe(2) + expect(result.has("/project/AGENTS.md")).toBe(true) + expect(result.has("/project/lib/AGENTS.md")).toBe(true) + }) +}) + +// ─── InstructionPrompt.resolve ────────────────────────────────────────────── describe("InstructionPrompt.resolve", () => { test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index e9c6cb729b..15aff2105d 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -896,3 +896,141 @@ describe("session.message-v2.fromError", () => { }) }) }) + +// ─── filterCompacted ──────────────────────────────────────────────────────── + +describe("session.message-v2.filterCompacted", () => { + // Helper to create a user message with optional compaction part + function userMsg(id: string, opts?: { compaction?: boolean }): MessageV2.WithParts { + const parts: MessageV2.Part[] = [] + if (opts?.compaction) { + parts.push({ + ...basePart(id, `${id}-compact`), + type: "compaction", + auto: true, + } as MessageV2.Part) + } + parts.push({ + ...basePart(id, `${id}-text`), + type: "text", + content: `user message ${id}`, + } as MessageV2.Part) + return { + info: { ...userInfo(id) } as MessageV2.User, + parts, + } + } + + // Helper to create an assistant message + function assistantMsg( + id: string, + parentID: string, + opts?: { summary?: boolean; finish?: string; error?: boolean }, + ): MessageV2.WithParts { + const info = assistantInfo(id, parentID) as any + if (opts?.summary) info.summary = true + if (opts?.finish) info.finish = opts.finish + if (opts?.error) { + info.error = { name: "UnknownError", data: { message: "something went wrong" } } + } + return { + info: info as MessageV2.Assistant, + parts: [ + { + ...basePart(id, `${id}-text`), + type: "text", + content: `assistant response ${id}`, + } as MessageV2.Part, + ], + } + } + + async function* toStream(msgs: MessageV2.WithParts[]) { + for (const msg of msgs) yield msg + } + + test("returns all messages reversed when no compaction point exists", async () => { + const u1 = userMsg("u1") + const a1 = assistantMsg("a1", "u1", { summary: true, finish: "stop" }) + const u2 = userMsg("u2") + const a2 = assistantMsg("a2", "u2", { summary: true, finish: "stop" }) + + // Stream is newest-first (reverse chronological, as the DB query returns) + const result = await MessageV2.filterCompacted(toStream([a2, u2, a1, u1])) + + // Reversed: oldest-first + expect(result.length).toBe(4) + expect(result[0].info.id).toBe("u1") + expect(result[1].info.id).toBe("a1") + expect(result[2].info.id).toBe("u2") + expect(result[3].info.id).toBe("a2") + }) + + test("stops at compaction boundary and returns slice reversed", async () => { + // Stream (newest-first): a3, u3, a2, u2(compacted), a1, u1 + // u2 has a compaction part AND a1 completed successfully with parentID=u1 + // But a2 completed with parentID=u2, so u2 is in completed set + const u1 = userMsg("u1") + const a1 = assistantMsg("a1", "u1", { summary: true, finish: "stop" }) + const u2 = userMsg("u2", { compaction: true }) + const a2 = assistantMsg("a2", "u2", { summary: true, finish: "stop" }) + const u3 = userMsg("u3") + const a3 = assistantMsg("a3", "u3", { summary: true, finish: "stop" }) + + const result = await MessageV2.filterCompacted(toStream([a3, u3, a2, u2, a1, u1])) + + // Should stop at u2 (has compaction part and is in completed set via a2) + // Collected: [a3, u3, a2, u2] then reversed + expect(result.length).toBe(4) + expect(result[0].info.id).toBe("u2") + expect(result[1].info.id).toBe("a2") + expect(result[2].info.id).toBe("u3") + expect(result[3].info.id).toBe("a3") + }) + + test("does not stop at user message with compaction part if no matching assistant completion", async () => { + // u2 has compaction part but no assistant completed with parentID=u2 + const u1 = userMsg("u1") + const a1 = assistantMsg("a1", "u1", { summary: true, finish: "stop" }) + const u2 = userMsg("u2", { compaction: true }) + // a2 is still running (no summary, no finish) + const a2 = assistantMsg("a2", "u2") + const u3 = userMsg("u3") + + const result = await MessageV2.filterCompacted(toStream([u3, a2, u2, a1, u1])) + + // Should NOT stop at u2 because u2 is not in the completed set + // All 5 messages returned, reversed + expect(result.length).toBe(5) + expect(result[0].info.id).toBe("u1") + expect(result[4].info.id).toBe("u3") + }) + + test("errored assistant does not mark parent as completed", async () => { + // a2 has error, summary, finish — but error should prevent marking u2 as completed + const u1 = userMsg("u1") + const a1 = assistantMsg("a1", "u1", { summary: true, finish: "stop" }) + const u2 = userMsg("u2", { compaction: true }) + const a2 = assistantMsg("a2", "u2", { summary: true, finish: "stop", error: true }) + const u3 = userMsg("u3") + + const result = await MessageV2.filterCompacted(toStream([u3, a2, u2, a1, u1])) + + // Should NOT stop at u2 because a2 has an error + expect(result.length).toBe(5) + expect(result[0].info.id).toBe("u1") + expect(result[4].info.id).toBe("u3") + }) + + test("requires both compaction part and completed set membership to stop", async () => { + // a1 completes for u1, so u1 is in completed set, but u1 has NO compaction part + const u1 = userMsg("u1") // no compaction part + const a1 = assistantMsg("a1", "u1", { summary: true, finish: "stop" }) + const u2 = userMsg("u2") + + const result = await MessageV2.filterCompacted(toStream([u2, a1, u1])) + + // Should NOT stop at u1 because u1 has no compaction part + expect(result.length).toBe(3) + }) +})