fix(cli): bound XLSX read input size

This commit is contained in:
marius-kilocode
2026-05-29 16:59:16 +02:00
45 changed files with 660 additions and 339 deletions
@@ -16,7 +16,11 @@ const it = testEffect(CrossSpawnSpawner.defaultLayer)
function layer(overrides: Partial<Config.Interface> = {}) {
return Layer.merge(
KiloSessions.layer.pipe(Layer.provideMerge(Bus.layer), Layer.provide(TestConfig.layer(overrides))),
KiloSessions.layer.pipe(
Layer.provideMerge(Bus.layer),
Layer.provide(TestConfig.layer(overrides)),
Layer.provide(Session.defaultLayer),
),
Auth.defaultLayer,
)
}
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import { Identifier } from "../../src/id/id"
@@ -16,6 +17,19 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const sessions = {
create: (input?: Parameters<Session.Interface["create"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))),
get: (id: SessionID) =>
Effect.runPromise(Session.Service.use((svc) => svc.get(id)).pipe(Effect.provide(Session.defaultLayer))),
messages: (input: Parameters<Session.Interface["messages"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))),
updateMessage: <T extends MessageV2.Info>(msg: T) =>
Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))),
updatePart: <T extends MessageV2.Part>(part: T) =>
Effect.runPromise(Session.Service.use((svc) => svc.updatePart(part)).pipe(Effect.provide(Session.defaultLayer))),
}
const model = {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
@@ -32,8 +46,8 @@ async function seed(input: {
tools?: Array<{ tool: string; input: Record<string, unknown>; output: string }>
finish?: string
}) {
const session = await Session.create({})
const user = await Session.updateMessage({
const session = await sessions.create({})
const user = await sessions.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
@@ -41,7 +55,7 @@ async function seed(input: {
agent: input.agent ?? "plan",
model,
})
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: user.id,
sessionID: session.id,
@@ -73,9 +87,9 @@ async function seed(input: {
},
finish: (input.finish as MessageV2.Assistant["finish"]) ?? "end_turn",
}
await Session.updateMessage(assistant)
await sessions.updateMessage(assistant)
if (input.text !== undefined) {
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: assistant.id,
sessionID: session.id,
@@ -85,7 +99,7 @@ async function seed(input: {
}
for (const t of input.tools ?? []) {
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: assistant.id,
sessionID: session.id,
@@ -103,7 +117,7 @@ async function seed(input: {
} satisfies MessageV2.ToolPart)
}
const messages = await Session.messages({ sessionID: session.id })
const messages = await sessions.messages({ sessionID: session.id })
return { sessionID: session.id, messages }
}
@@ -217,7 +231,7 @@ describe("plan_exit detection", () => {
await expect(pending).resolves.toBe("continue")
const messages = await Session.messages({ sessionID: seeded.sessionID })
const messages = await sessions.messages({ sessionID: seeded.sessionID })
const user = messages
.slice()
.reverse()
@@ -239,8 +253,8 @@ describe("plan_exit detection", () => {
test("plan_exit with non-completed status does NOT trigger", () =>
withInstance(async () => {
const session = await Session.create({})
const user = await Session.updateMessage({
const session = await sessions.create({})
const user = await sessions.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
@@ -248,7 +262,7 @@ describe("plan_exit detection", () => {
agent: "plan",
model,
})
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: user.id,
sessionID: session.id,
@@ -277,15 +291,15 @@ describe("plan_exit detection", () => {
},
finish: "end_turn",
}
await Session.updateMessage(assistant)
await Session.updatePart({
await sessions.updateMessage(assistant)
await sessions.updatePart({
id: PartID.ascending(),
messageID: assistant.id,
sessionID: session.id,
type: "text",
text: "Here is the plan",
})
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: assistant.id,
sessionID: session.id,
@@ -301,7 +315,7 @@ describe("plan_exit detection", () => {
},
} satisfies MessageV2.ToolPart)
const messages = await Session.messages({ sessionID: session.id })
const messages = await sessions.messages({ sessionID: session.id })
// Verify the tool part IS present but errored (not completed)
const toolPart = messages.flatMap((msg) => msg.parts).find((p) => p.type === "tool" && p.tool === "plan_exit")
@@ -318,10 +332,10 @@ describe("plan_exit detection", () => {
test("plan_exit on earlier assistant message triggers when later message has text only", () =>
withInstance(async () => {
const session = await Session.create({})
const session = await sessions.create({})
// Use explicit timestamps to ensure deterministic message ordering
const now = Date.now()
const user = await Session.updateMessage({
const user = await sessions.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
@@ -329,7 +343,7 @@ describe("plan_exit detection", () => {
agent: "plan",
model,
})
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: user.id,
sessionID: session.id,
@@ -353,8 +367,8 @@ describe("plan_exit detection", () => {
tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "tool-calls",
}
await Session.updateMessage(assistant1)
await Session.updatePart({
await sessions.updateMessage(assistant1)
await sessions.updatePart({
id: PartID.ascending(),
messageID: assistant1.id,
sessionID: session.id,
@@ -387,8 +401,8 @@ describe("plan_exit detection", () => {
tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "end_turn",
}
await Session.updateMessage(assistant2)
await Session.updatePart({
await sessions.updateMessage(assistant2)
await sessions.updatePart({
id: PartID.ascending(),
messageID: assistant2.id,
sessionID: session.id,
@@ -396,7 +410,7 @@ describe("plan_exit detection", () => {
text: "The plan is complete. I've called plan_exit.",
})
const messages = await Session.messages({ sessionID: session.id })
const messages = await sessions.messages({ sessionID: session.id })
expect(SessionPrompt.shouldAskPlanFollowup({ messages, abort: AbortSignal.any([]) })).toBe(true)
}))
@@ -412,7 +426,7 @@ describe("plan_exit detection", () => {
],
})
const session = await Session.get(seeded.sessionID)
const session = await sessions.get(seeded.sessionID)
const plan = Session.plan(session, Instance.current)
await fs.mkdir(path.dirname(plan), { recursive: true })
await Bun.write(plan, "Do implementation step 1")
@@ -435,10 +449,10 @@ describe("plan_exit detection", () => {
test("PlanFollowup.ask shows prompt when plan text is on earlier assistant and last assistant is empty", () =>
withInstance(async () => {
const session = await Session.create({})
const session = await sessions.create({})
// Use explicit timestamps to ensure deterministic message ordering
const now = Date.now()
const user = await Session.updateMessage({
const user = await sessions.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
@@ -446,7 +460,7 @@ describe("plan_exit detection", () => {
agent: "plan",
model,
})
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: user.id,
sessionID: session.id,
@@ -470,15 +484,15 @@ describe("plan_exit detection", () => {
tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "tool-calls",
}
await Session.updateMessage(assistant1)
await Session.updatePart({
await sessions.updateMessage(assistant1)
await sessions.updatePart({
id: PartID.ascending(),
messageID: assistant1.id,
sessionID: session.id,
type: "text",
text: "Here is the detailed plan:\n\n## Step 1\nDo something\n\n## Step 2\nDo something else",
})
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: assistant1.id,
sessionID: session.id,
@@ -511,9 +525,9 @@ describe("plan_exit detection", () => {
tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "end_turn",
}
await Session.updateMessage(assistant2)
await sessions.updateMessage(assistant2)
const messages = await Session.messages({ sessionID: session.id })
const messages = await sessions.messages({ sessionID: session.id })
// shouldAskPlanFollowup should detect plan_exit on the earlier message
expect(SessionPrompt.shouldAskPlanFollowup({ messages, abort: AbortSignal.any([]) })).toBe(true)
@@ -46,6 +46,19 @@ const todo = {
},
}
const store = {
create: (input?: Parameters<Session.Interface["create"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))),
get: (id: SessionID) =>
Effect.runPromise(Session.Service.use((svc) => svc.get(id)).pipe(Effect.provide(Session.defaultLayer))),
messages: (input: Parameters<Session.Interface["messages"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))),
updateMessage: <T extends MessageV2.Info>(msg: T) =>
Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))),
updatePart: <T extends MessageV2.Part>(part: T) =>
Effect.runPromise(Session.Service.use((svc) => svc.updatePart(part)).pipe(Effect.provide(Session.defaultLayer))),
}
const model = {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
@@ -90,8 +103,8 @@ async function seed(input: {
variant?: string
tools?: Array<{ tool: string; input: Record<string, unknown>; output: string }>
}) {
const session = await Session.create({})
const user = await Session.updateMessage({
const session = await store.create({})
const user = await store.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
@@ -101,7 +114,7 @@ async function seed(input: {
agent: "plan",
model: input.variant ? { ...model, variant: input.variant } : model,
})
await Session.updatePart({
await store.updatePart({
id: PartID.ascending(),
messageID: user.id,
sessionID: session.id,
@@ -138,8 +151,8 @@ async function seed(input: {
},
finish: "end_turn",
}
await Session.updateMessage(assistant)
await Session.updatePart({
await store.updateMessage(assistant)
await store.updatePart({
id: PartID.ascending(),
messageID: assistant.id,
sessionID: session.id,
@@ -148,7 +161,7 @@ async function seed(input: {
})
for (const t of input.tools ?? []) {
await Session.updatePart({
await store.updatePart({
id: PartID.ascending(),
messageID: assistant.id,
sessionID: session.id,
@@ -166,7 +179,7 @@ async function seed(input: {
} satisfies MessageV2.ToolPart)
}
const messages = await Session.messages({ sessionID: session.id })
const messages = await store.messages({ sessionID: session.id })
return {
sessionID: session.id,
messages,
@@ -174,7 +187,7 @@ async function seed(input: {
}
async function latestUser(sessionID: SessionID) {
const messages = await Session.messages({ sessionID })
const messages = await store.messages({ sessionID })
return messages
.slice()
.reverse()
@@ -500,7 +513,7 @@ describe("plan follow-up", () => {
await expect(pending).resolves.toBe("continue")
// The injected user message must be visible when scoped
const all = await Session.messages({ sessionID: seeded.sessionID })
const all = await store.messages({ sessionID: seeded.sessionID })
const scoped = KiloSessionPromptQueue.scope(seeded.sessionID, all)
const injected = scoped.findLast((m) => m.info.role === "user")
expect(injected).toBeDefined()
@@ -629,8 +642,8 @@ describe("plan follow-up", () => {
if (!newSessionID || !next) throw new Error("expected follow-up session")
expect(next.id).toBe(newSessionID)
expect(next.parentID).toBeUndefined()
const planPath = Session.plan(await Session.get(seeded.sessionID), Instance.current)
const messages = await Session.messages({ sessionID: newSessionID })
const planPath = Session.plan(await store.get(seeded.sessionID), Instance.current)
const messages = await store.messages({ sessionID: newSessionID })
const user = messages.find((item) => item.info.role === "user")
expect(user?.info.role).toBe("user")
if (!user || user.info.role !== "user") throw new Error("expected seeded user message")
@@ -737,9 +750,9 @@ describe("plan follow-up", () => {
if (next) {
const planPath = await WithInstance.provide({
directory: dir,
fn: async () => Session.plan(await Session.get(seeded.sessionID), Instance.current),
fn: async () => Session.plan(await store.get(seeded.sessionID), Instance.current),
})
const messages = await Session.messages({ sessionID: next.id })
const messages = await store.messages({ sessionID: next.id })
const user = messages.find((item) => item.info.role === "user")
if (!user || user.info.role !== "user") throw new Error("expected user message")
const part = user.parts.find((item) => item.type === "text")
@@ -947,7 +960,7 @@ describe("plan follow-up", () => {
const newSessionID = created[0]
if (!newSessionID) throw new Error("expected follow-up session")
const messages = await Session.messages({ sessionID: newSessionID })
const messages = await store.messages({ sessionID: newSessionID })
const user = messages.find((item) => item.info.role === "user")
if (!user || user.info.role !== "user") throw new Error("expected user message")
const part = user.parts.find((item) => item.type === "text")
@@ -1111,7 +1124,7 @@ describe("plan follow-up", () => {
// deferred has not resolved yet.
for (let i = 0; i < 100; i++) {
if (followup) {
const msgs = await Session.messages({ sessionID: followup })
const msgs = await store.messages({ sessionID: followup })
const user = msgs.find((m) => m.info.role === "user")
const part = user?.parts.find((p) => p.type === "text")
if (part?.type === "text" && part.text.includes("Implement the following plan:")) break
@@ -1121,7 +1134,7 @@ describe("plan follow-up", () => {
expect(followup).toBeDefined()
if (!followup) return
const initial = await Session.messages({ sessionID: followup })
const initial = await store.messages({ sessionID: followup })
const initialUser = initial.find((m) => m.info.role === "user")
const initialPart = initialUser?.parts.find((p) => p.type === "text")
expect(initialPart?.type).toBe("text")
@@ -1135,7 +1148,7 @@ describe("plan follow-up", () => {
await expect(pending).resolves.toBe("break")
// Same part ID updated in-place — handover section now present.
const final = await Session.messages({ sessionID: followup })
const final = await store.messages({ sessionID: followup })
const finalUser = final.find((m) => m.info.role === "user")
const finalPart = finalUser?.parts.find((p) => p.type === "text")
if (finalPart?.type !== "text") return
@@ -1294,7 +1307,7 @@ describe("plan follow-up", () => {
})
await expect(pending).resolves.toBe("break")
expect((await Session.messages({ sessionID: seeded.sessionID })).length).toBe(2)
expect((await store.messages({ sessionID: seeded.sessionID })).length).toBe(2)
}))
test("formatTodos - returns empty string for no todos", () => {
@@ -0,0 +1,196 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import path from "path"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { LSP } from "../../src/lsp/lsp"
import { Instruction } from "../../src/session/instruction"
import { MessageID, SessionID } from "../../src/session/schema"
import { ReadTool } from "../../src/tool/read"
import * as Tool from "../../src/tool/tool"
import { Truncate } from "../../src/tool/truncate"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const ctx = {
sessionID: SessionID.make("ses_test-notebook"),
messageID: MessageID.make(""),
callID: "",
agent: "code",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
Truncate.defaultLayer,
),
)
const run = Effect.fn("NotebookReadTest.run")(function* (dir: string, args: Tool.InferParameters<typeof ReadTool>) {
return yield* provideInstance(dir)(
Effect.gen(function* () {
const info = yield* ReadTool
const tool = yield* Tool.init(info)
return yield* tool.execute(args, ctx)
}),
)
})
const put = Effect.fn("NotebookReadTest.put")(function* (filepath: string, content: string | Uint8Array) {
const fs = yield* AppFileSystem.Service
yield* fs.writeWithDirs(filepath, content)
})
const notebook = JSON.stringify({
metadata: { secret: "ignore-notebook-metadata" },
cells: [
{
cell_type: "markdown",
metadata: { private: "ignore-cell-metadata" },
source: ["# Analysis\n", "Useful introduction"],
},
{
cell_type: "raw",
source: ["ignore raw cell"],
},
{
cell_type: "code",
execution_count: 7,
metadata: {},
source: ["value = 42\n", "print(value)"],
outputs: [{ output_type: "stream", text: ["ignore-output-payload"] }],
},
],
})
describe("kilocode notebook reads", () => {
it.live("extracts markdown and code cells without notebook payloads", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const filepath = path.join(dir, "analysis.ipynb")
yield* put(filepath, notebook)
const result = yield* run(dir, { filePath: filepath })
expect(result.output).toContain("<markdown_cell>")
expect(result.output).toContain("# Analysis")
expect(result.output).toContain("<code_cell>")
expect(result.output).toContain("value = 42")
expect(result.output.indexOf("# Analysis")).toBeLessThan(result.output.indexOf("value = 42"))
expect(result.output).not.toContain("ignore-output-payload")
expect(result.output).not.toContain("ignore-notebook-metadata")
expect(result.output).not.toContain("ignore-cell-metadata")
expect(result.output).not.toContain("ignore raw cell")
}),
)
it.live("skips invalid cells without exposing raw notebook payloads", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const filepath = path.join(dir, "partial.ipynb")
const content = JSON.stringify({
cells: [
null,
{ cell_type: "code", source: null, outputs: ["INVALID_OUTPUT_SHOULD_NOT_APPEAR"] },
{ cell_type: "markdown", source: ["Readable cell"], metadata: { marker: "CELL_METADATA_SHOULD_NOT_APPEAR" } },
],
})
yield* put(filepath, content)
const result = yield* run(dir, { filePath: filepath })
expect(result.output).toContain("Readable cell")
expect(result.output).not.toContain("INVALID_OUTPUT_SHOULD_NOT_APPEAR")
expect(result.output).not.toContain("CELL_METADATA_SHOULD_NOT_APPEAR")
}),
)
it.live("reports valid notebooks with no readable cells without exposing payloads", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const filepath = path.join(dir, "empty-content.ipynb")
const content = JSON.stringify({
metadata: { marker: "NOTEBOOK_METADATA_SHOULD_NOT_APPEAR" },
cells: [
null,
{ cell_type: "raw", source: ["RAW_CONTENT_SHOULD_NOT_APPEAR"] },
{ cell_type: "code", source: null, outputs: ["INVALID_OUTPUT_SHOULD_NOT_APPEAR"] },
],
})
yield* put(filepath, content)
const result = yield* run(dir, { filePath: filepath })
expect(result.output).toContain("Notebook contains no markdown or code cell content")
expect(result.output).not.toContain("NOTEBOOK_METADATA_SHOULD_NOT_APPEAR")
expect(result.output).not.toContain("RAW_CONTENT_SHOULD_NOT_APPEAR")
expect(result.output).not.toContain("INVALID_OUTPUT_SHOULD_NOT_APPEAR")
}),
)
it.live("applies read pagination to extracted cell text", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const filepath = path.join(dir, "paged.ipynb")
yield* put(filepath, notebook)
const result = yield* run(dir, { filePath: filepath, offset: 2, limit: 2 })
expect(result.output).toContain("2: # Analysis")
expect(result.output).toContain("3: Useful introduction")
expect(result.output).not.toContain("value = 42")
expect(result.metadata.preview).toBe("# Analysis\nUseful introduction")
expect(result.metadata.truncated).toBe(true)
}),
)
it.live("falls back to raw text for malformed notebooks", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const filepath = path.join(dir, "broken.ipynb")
const content = '{"cells":[{"cell_type":"markdown","source":["unfinished"]}'
yield* put(filepath, content)
const result = yield* run(dir, { filePath: filepath })
expect(result.output).toContain(content)
expect(result.output).not.toContain("<markdown_cell>")
}),
)
it.live("keeps ordinary text reads unchanged", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const filepath = path.join(dir, "notes.txt")
yield* put(filepath, "plain text")
const result = yield* run(dir, { filePath: filepath })
expect(result.output).toContain("1: plain text")
expect(result.output).not.toContain("<markdown_cell>")
}),
)
it.live("keeps PDF files as native attachments", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const filepath = path.join(dir, "document.pdf")
yield* put(filepath, "%PDF-1.4\nminimal content")
const result = yield* run(dir, { filePath: filepath })
expect(result.output).toBe("PDF read successfully")
expect(result.attachments?.[0].mime).toBe("application/pdf")
expect(result.metadata.truncated).toBe(false)
}),
)
})
@@ -1,5 +1,6 @@
import { Cause, Effect, Exit, Layer } from "effect"
import { describe, expect } from "bun:test"
import { truncate } from "fs/promises"
import path from "path"
import { write, utils, type WorkBook, type WorkSheet } from "xlsx"
import { TextReader, TextWriter, Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter } from "@zip.js/zip.js"
@@ -198,6 +199,20 @@ describe("kilocode XLSX reads", () => {
}),
)
it.live("rejects spreadsheets larger than the parser input limit", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const file = path.join(dir, "large-input.xlsx")
yield* put(file, new Uint8Array([0x50, 0x4b]))
yield* Effect.promise(() => truncate(file, 50 * 1024 * 1024 + 1))
const err = yield* fail(dir, file)
expect(err.message).toContain("Cannot read spreadsheet file")
expect(err.message).toContain("exceeds the 50 MB size limit")
}),
)
it.live("continues rejecting unsupported workbook formats as binary", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
@@ -17,6 +17,7 @@ const bus = Bus.layer
const env = Layer.mergeAll(
Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)),
Config.defaultLayer,
Session.defaultLayer,
bus,
CrossSpawnSpawner.defaultLayer,
)
@@ -72,10 +73,11 @@ describe("AllowEverythingPermission", () => {
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
expect(yield* AllowEverythingPermission.effect({ enable: true })).toBe(true)
expect(yield* AllowEverythingPermission.effect({ enable: false })).toBe(true)
const session = yield* Effect.promise(() => Session.create({}))
const session = yield* sessions.create({})
const pending = yield* ask({
id: PermissionID.make("permission_global_disable"),
sessionID: session.id,
@@ -106,16 +108,15 @@ describe("AllowEverythingPermission", () => {
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const session = yield* Effect.promise(() =>
Session.create({
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
const sessions = yield* Session.Service
const session = yield* sessions.create({
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
expect(yield* AllowEverythingPermission.effect({ enable: true, sessionID: session.id })).toBe(true)
expect(yield* AllowEverythingPermission.effect({ enable: false, sessionID: session.id })).toBe(true)
const next = yield* Effect.promise(() => Session.get(session.id))
const next = yield* sessions.get(session.id)
expect(next.permission ?? []).toEqual([])
const pending = yield* ask({
@@ -140,7 +141,7 @@ describe("AllowEverythingPermission", () => {
expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
}
const other = yield* Effect.promise(() => Session.create({}))
const other = yield* sessions.create({})
const blocked = yield* ask({
id: PermissionID.make("permission_other_session"),
sessionID: other.id,
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { WithInstance } from "../../src/project/with-instance"
import { Session } from "../../src/session/session"
import { MessageV2 } from "../../src/session/message-v2"
@@ -8,6 +9,19 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const sessions = {
create: (input?: Parameters<Session.Interface["create"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))),
get: (id: SessionID) =>
Effect.runPromise(Session.Service.use((svc) => svc.get(id)).pipe(Effect.provide(Session.defaultLayer))),
messages: (input: Parameters<Session.Interface["messages"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))),
updateMessage: <T extends MessageV2.Info>(msg: T) =>
Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))),
updatePart: <T extends MessageV2.Part>(part: T) =>
Effect.runPromise(Session.Service.use((svc) => svc.updatePart(part)).pipe(Effect.provide(Session.defaultLayer))),
}
afterEach(async () => {
await disposeAllInstances()
})
@@ -36,7 +50,7 @@ function taskPart(input: { messageID: string; sessionID: string; childSessionID:
async function userMsg(sid: string) {
const id = MessageID.ascending()
await Session.updateMessage({
await sessions.updateMessage({
id,
sessionID: SessionID.make(sid),
role: "user",
@@ -50,7 +64,7 @@ async function userMsg(sid: string) {
async function asstMsg(sid: string, parent: string) {
const id = MessageID.ascending()
await Session.updateMessage({
await sessions.updateMessage({
id,
sessionID: SessionID.make(sid),
role: "assistant",
@@ -75,12 +89,12 @@ describe("Session.fork child session remapping", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const parent = await Session.create({ title: "parent" })
const child = await Session.create({ parentID: parent.id, title: "child subagent" })
const parent = await sessions.create({ title: "parent" })
const child = await sessions.create({ parentID: parent.id, title: "child subagent" })
// Add a user message to the child so it has content
const childMsgId = await userMsg(child.id)
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: childMsgId,
sessionID: child.id,
@@ -90,7 +104,7 @@ describe("Session.fork child session remapping", () => {
// Add a user message then an assistant message with a task tool part referencing the child
const parentUserMsg = await userMsg(parent.id)
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: parentUserMsg,
sessionID: parent.id,
@@ -99,7 +113,7 @@ describe("Session.fork child session remapping", () => {
} as MessageV2.TextPart)
const parentAsstMsg = await asstMsg(parent.id, parentUserMsg)
await Session.updatePart(
await sessions.updatePart(
taskPart({
messageID: parentAsstMsg,
sessionID: parent.id,
@@ -112,7 +126,7 @@ describe("Session.fork child session remapping", () => {
expect(forked.id).not.toBe(parent.id)
// Check that the forked session's task part references a DIFFERENT child session
const forkedMsgs = await Session.messages({ sessionID: forked.id })
const forkedMsgs = await sessions.messages({ sessionID: forked.id })
const parts = forkedMsgs.flatMap((m) => m.parts)
const tools = parts.filter((p) => p.type === "tool" && p.tool === "task") as MessageV2.ToolPart[]
@@ -121,11 +135,11 @@ describe("Session.fork child session remapping", () => {
expect(meta.sessionId).not.toBe(child.id)
// Verify the forked child session actually exists and has content
const forkedChild = await Session.get(SessionID.make(meta.sessionId))
const forkedChild = await sessions.get(SessionID.make(meta.sessionId))
expect(forkedChild).toBeDefined()
expect(forkedChild.id).not.toBe(child.id)
const forkedChildMsgs = await Session.messages({ sessionID: forkedChild.id })
const forkedChildMsgs = await sessions.messages({ sessionID: forkedChild.id })
expect(forkedChildMsgs).toHaveLength(1)
expect(forkedChildMsgs[0].parts[0].type).toBe("text")
},
@@ -142,13 +156,13 @@ describe("Session.fork child session remapping", () => {
directory: tmp.path,
fn: async () => {
// grandchild -> child -> parent
const parent = await Session.create({ title: "parent" })
const child = await Session.create({ parentID: parent.id, title: "child" })
const grandchild = await Session.create({ parentID: child.id, title: "grandchild" })
const parent = await sessions.create({ title: "parent" })
const child = await sessions.create({ parentID: parent.id, title: "child" })
const grandchild = await sessions.create({ parentID: child.id, title: "grandchild" })
// grandchild has a text message
const gcMsgId = await userMsg(grandchild.id)
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: gcMsgId,
sessionID: grandchild.id,
@@ -158,7 +172,7 @@ describe("Session.fork child session remapping", () => {
// child references grandchild via task part
const childUserMsg = await userMsg(child.id)
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: childUserMsg,
sessionID: child.id,
@@ -166,7 +180,7 @@ describe("Session.fork child session remapping", () => {
text: "question",
} as MessageV2.TextPart)
const childAsstMsg = await asstMsg(child.id, childUserMsg)
await Session.updatePart(
await sessions.updatePart(
taskPart({
messageID: childAsstMsg,
sessionID: child.id,
@@ -176,7 +190,7 @@ describe("Session.fork child session remapping", () => {
// parent references child via task part
const parentUserMsg = await userMsg(parent.id)
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: parentUserMsg,
sessionID: parent.id,
@@ -184,7 +198,7 @@ describe("Session.fork child session remapping", () => {
text: "request",
} as MessageV2.TextPart)
const parentAsstMsg = await asstMsg(parent.id, parentUserMsg)
await Session.updatePart(
await sessions.updatePart(
taskPart({
messageID: parentAsstMsg,
sessionID: parent.id,
@@ -195,7 +209,7 @@ describe("Session.fork child session remapping", () => {
const forked = await Session.fork({ sessionID: parent.id })
// Verify parent-level remap
const forkedMsgs = await Session.messages({ sessionID: forked.id })
const forkedMsgs = await sessions.messages({ sessionID: forked.id })
const tools = forkedMsgs
.flatMap((m) => m.parts)
.filter((p) => p.type === "tool" && p.tool === "task") as MessageV2.ToolPart[]
@@ -203,7 +217,7 @@ describe("Session.fork child session remapping", () => {
expect(forkedChildID).not.toBe(child.id)
// Verify child-level remap (grandchild)
const forkedChildMsgs = await Session.messages({ sessionID: SessionID.make(forkedChildID) })
const forkedChildMsgs = await sessions.messages({ sessionID: SessionID.make(forkedChildID) })
const childTools = forkedChildMsgs
.flatMap((m) => m.parts)
.filter((p) => p.type === "tool" && p.tool === "task") as MessageV2.ToolPart[]
@@ -213,7 +227,7 @@ describe("Session.fork child session remapping", () => {
expect(forkedGrandchildID).not.toBe(grandchild.id)
// Verify grandchild content was copied
const gcMsgs = await Session.messages({ sessionID: SessionID.make(forkedGrandchildID) })
const gcMsgs = await sessions.messages({ sessionID: SessionID.make(forkedGrandchildID) })
expect(gcMsgs).toHaveLength(1)
},
})
@@ -228,9 +242,9 @@ describe("Session.fork child session remapping", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const parent = await Session.create({ title: "parent" })
const parent = await sessions.create({ title: "parent" })
const parentUserMsg = await userMsg(parent.id)
await Session.updatePart({
await sessions.updatePart({
id: PartID.ascending(),
messageID: parentUserMsg,
sessionID: parent.id,
@@ -239,7 +253,7 @@ describe("Session.fork child session remapping", () => {
} as MessageV2.TextPart)
const forked = await Session.fork({ sessionID: parent.id })
const forkedMsgs = await Session.messages({ sessionID: forked.id })
const forkedMsgs = await sessions.messages({ sessionID: forked.id })
expect(forkedMsgs).toHaveLength(1)
expect(forkedMsgs[0].parts[0].type).toBe("text")
expect((forkedMsgs[0].parts[0] as MessageV2.TextPart).text).toBe("hello")
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { WithInstance } from "../../src/project/with-instance"
import { ProjectTable } from "../../src/project/project.sql"
import { ProjectID } from "../../src/project/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Session } from "../../src/session/session"
import { SessionTable } from "../../src/session/session.sql"
import { Database, eq } from "../../src/storage/db"
@@ -21,7 +21,9 @@ describe("Kilo Session.list", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "legacy-session" })
const session = await Effect.runPromise(
Session.Service.use((svc) => svc.create({ title: "legacy-session" })).pipe(Effect.provide(Session.defaultLayer)),
)
const project = ProjectID.make("legacy-project")
Database.use((db) => {
db.insert(ProjectTable)
@@ -37,7 +39,9 @@ describe("Kilo Session.list", () => {
db.update(SessionTable).set({ project_id: project }).where(eq(SessionTable.id, session.id)).run()
})
const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list({ directory: tmp.path })))
const sessions = await Effect.runPromise(
Session.Service.use((svc) => svc.list({ directory: tmp.path })).pipe(Effect.provide(Session.defaultLayer)),
)
const ids = sessions.map((item) => item.id)
expect(ids).toContain(session.id)
@@ -17,6 +17,15 @@ import { provideInstance, tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const sessions = {
create: (input?: Parameters<Session.Interface["create"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))),
messages: (input: Parameters<Session.Interface["messages"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))),
updateMessage: <T extends MessageV2.Info>(msg: T) =>
Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))),
}
function line(input: unknown) {
return `data: ${JSON.stringify(input)}\n\n`
}
@@ -259,14 +268,14 @@ describe("session prompt queue", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Queued compaction regression" })
const session = await sessions.create({ title: "Queued compaction regression" })
const first = MessageID.ascending()
const ans = MessageID.ascending()
const queued = MessageID.ascending()
await Session.updateMessage(user(session.id, first).info)
await Session.updateMessage(assistant(session.id, ans, first).info)
await Session.updateMessage(user(session.id, queued).info)
await sessions.updateMessage(user(session.id, first).info)
await sessions.updateMessage(assistant(session.id, ans, first).info)
await sessions.updateMessage(user(session.id, queued).info)
const result = await Effect.runPromise(
KiloSessionPromptQueue.enqueue(
@@ -280,7 +289,7 @@ describe("session prompt queue", () => {
auto: true,
overflow: true,
})
const messages = await Session.messages({ sessionID: session.id })
const messages = await sessions.messages({ sessionID: session.id })
const compact = messages.find((msg) => msg.parts.some((part) => part.type === "compaction"))?.info.id
return { compact, ids: KiloSessionPromptQueue.scope(session.id, messages).map((item) => item.info.id) }
}),
@@ -426,7 +435,7 @@ describe("session prompt queue", () => {
directory: tmp.path,
fn: async () =>
scoped(tmp.path, async (prompt) => {
const session = await Session.create({ title: "Queued prompt regression" })
const session = await sessions.create({ title: "Queued prompt regression" })
const first = Effect.runPromise(
prompt.prompt({
sessionID: session.id,
@@ -457,7 +466,7 @@ describe("session prompt queue", () => {
expect(hasText(one, "first reply")).toBe(true)
expect(hasText(two, "second reply")).toBe(true)
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await sessions.messages({ sessionID: session.id })
const users = msgs.filter((msg) => msg.info.role === "user")
const assistants = msgs.filter((msg) => msg.info.role === "assistant")
const prompts = users.flatMap((msg) =>
@@ -548,7 +557,7 @@ describe("session prompt queue", () => {
directory: tmp.path,
fn: async () =>
scoped(tmp.path, async (prompt) => {
const session = await Session.create({ title: "Queued cancel regression" })
const session = await sessions.create({ title: "Queued cancel regression" })
const first = Effect.runPromise(
prompt.prompt({
sessionID: session.id,
@@ -582,7 +591,7 @@ describe("session prompt queue", () => {
// The queued prompts must never reach the LLM once cancel flushes the queue.
expect(calls).toHaveLength(1)
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await sessions.messages({ sessionID: session.id })
const assistants = msgs.filter((msg) => msg.info.role === "assistant")
expect(assistants).toHaveLength(1)
expect(msgs.filter((msg) => msg.info.role === "user")).toHaveLength(3)
@@ -614,7 +623,7 @@ describe("session prompt queue", () => {
directory: tmp.path,
fn: async () =>
scoped(tmp.path, async (prompt) => {
const session = await Session.create({ title: "Suggestion unblock regression" })
const session = await sessions.create({ title: "Suggestion unblock regression" })
const offShown = Bus.subscribe(Suggestion.Event.Shown, (event) => {
if (event.properties.sessionID === session.id) shown.resolve()
})
@@ -662,7 +671,7 @@ describe("session prompt queue", () => {
directory: tmp.path,
fn: async () =>
scoped(tmp.path, async (prompt) => {
const session = await Session.create({ title: "Question unblock regression" })
const session = await sessions.create({ title: "Question unblock regression" })
const offAsked = Bus.subscribe(Question.Event.Asked, (event) => {
if (event.properties.sessionID === session.id) asked.resolve()
})
@@ -25,8 +25,10 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
function run<A>(body: (snapshot: Snapshot.Interface) => Effect.Effect<A>) {
return Effect.runPromise(Snapshot.Service.use(body).pipe(Effect.provide(Snapshot.defaultLayer)))
function run<A>(body: (snapshot: Snapshot.Interface) => Effect.Effect<A, never, Session.Service>) {
return Effect.runPromise(
Snapshot.Service.use(body).pipe(Effect.provide(Snapshot.defaultLayer), Effect.provide(Session.defaultLayer)),
)
}
afterEach(async () => {
@@ -55,7 +57,8 @@ test("pathological diffFull workload finishes quickly and does not block abort",
fn: () =>
run((snapshot) =>
Effect.gen(function* () {
const session = yield* Effect.promise(() => Session.create({}))
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const before = yield* snapshot.track()
expect(before).toBeTruthy()
@@ -7,6 +7,8 @@ import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { RemoteSender } from "../../src/kilo-sessions/remote-sender"
import { Effect } from "effect"
import { Session } from "../../src/session/session"
beforeEach(() => {
spyOn(RemoteSender, "create").mockReturnValue({ handle() {}, dispose() {} })
@@ -19,6 +21,9 @@ afterEach(async () => {
await resetDatabase()
})
const create = (title: string) =>
Effect.runPromise(Session.Service.use((svc) => svc.create({ title })).pipe(Effect.provide(Session.defaultLayer)))
describe("experimental.session.list", () => {
test("filters sessions by repo worktree family even when project IDs drift", async () => {
await using first = await tmpdir({ git: true })
@@ -30,12 +35,11 @@ describe("experimental.session.list", () => {
try {
const { Server } = await import("../../src/server/server")
const { Session } = await import("../../src/session/session")
// Create worktree session first so it computes its own project ID via rev-list
const branch = await WithInstance.provide({
directory: worktree,
fn: async () => Session.create({ title: "worktree-session" }),
fn: () => create("worktree-session"),
})
// Now write a stale project ID to .git/kilo — this overrides the root's cached ID
@@ -48,14 +52,14 @@ describe("experimental.session.list", () => {
project: await Server.Default().app.request("/project/current", {
headers: { "x-kilo-directory": first.path },
}),
session: await Session.create({ title: "root-session" }),
session: await create("root-session"),
}),
})
await Bun.file(path.join(first.path, ".git", "kilo")).delete()
await WithInstance.provide({
directory: second.path,
fn: async () => Session.create({ title: "other-project-session" }),
fn: () => create("other-project-session"),
})
const app = root.app
@@ -96,11 +100,10 @@ describe("experimental.session.list", () => {
try {
const { Server } = await import("../../src/server/server")
const { Session } = await import("../../src/session/session")
const branch = await WithInstance.provide({
directory: worktree,
fn: async () => Session.create({ title: "worktree-session" }),
fn: () => create("worktree-session"),
})
const root = await WithInstance.provide({
@@ -110,13 +113,13 @@ describe("experimental.session.list", () => {
project: await Server.Default().app.request("/project/current", {
headers: { "x-kilo-directory": first.path },
}),
session: await Session.create({ title: "root-session" }),
session: await create("root-session"),
}),
})
await WithInstance.provide({
directory: second.path,
fn: async () => Session.create({ title: "other-project-session" }),
fn: () => create("other-project-session"),
})
const app = root.app
+9 -8
View File
@@ -2,6 +2,7 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import { $ } from "bun"
import { Effect } from "effect"
import { Session } from "../../src/session/session"
import path from "path"
import { WithInstance } from "../../src/project/with-instance"
import { RecallTool } from "../../src/tool/recall"
@@ -32,6 +33,9 @@ afterEach(async () => {
await resetDatabase()
})
const create = (title: string) =>
Effect.runPromise(Session.Service.use((svc) => svc.create({ title })).pipe(Effect.provide(Session.defaultLayer)))
describe("tool.recall", () => {
test("search is limited to the current project worktrees", async () => {
await using first = await tmpdir({ git: true })
@@ -43,18 +47,17 @@ describe("tool.recall", () => {
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
try {
const { Session } = await import("../../src/session/session")
await WithInstance.provide({
directory: first.path,
fn: async () => Session.create({ title: "search-target root" }),
fn: () => create("search-target root"),
})
await WithInstance.provide({
directory: worktree,
fn: async () => Session.create({ title: "search-target worktree" }),
fn: () => create("search-target worktree"),
})
await WithInstance.provide({
directory: second.path,
fn: async () => Session.create({ title: "search-target other" }),
fn: () => create("search-target other"),
})
const result = await WithInstance.provide({
@@ -82,10 +85,9 @@ describe("tool.recall", () => {
await using second = await tmpdir({ git: true })
try {
const { Session } = await import("../../src/session/session")
const session = await WithInstance.provide({
directory: second.path,
fn: async () => Session.create({ title: "other-project-session" }),
fn: () => create("other-project-session"),
})
const err = await WithInstance.provide({
@@ -115,10 +117,9 @@ describe("tool.recall", () => {
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
try {
const { Session } = await import("../../src/session/session")
const session = await WithInstance.provide({
directory: worktree,
fn: async () => Session.create({ title: "worktree readable" }),
fn: () => create("worktree readable"),
})
const result = await WithInstance.provide({