Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions packages/opencode/test/session/instruction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
138 changes: 138 additions & 0 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Loading