diff --git a/.changeset/read-notebook-cells.md b/.changeset/read-notebook-cells.md new file mode 100644 index 00000000000..00df018eb2a --- /dev/null +++ b/.changeset/read-notebook-cells.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Read Jupyter notebooks as ordered markdown and code cell content instead of raw notebook payloads. diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 7d2aecf2c4f..865b560da7f 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -214,6 +214,7 @@ export namespace KiloSessions { Effect.gen(function* () { const bus = yield* Bus.Service const config = yield* Config.Service + const sessions = yield* Session.Service const state = yield* InstanceState.make( Effect.fn("KiloSessions.state")(function* () { if (ingestDisabled) return @@ -239,7 +240,7 @@ export namespace KiloSessions { }) yield* watch(Session.Event.Updated, async (evt) => { const sessionID = evt.properties.sessionID - const session = await Session.get(sessionID).catch(() => null) + const session = await Effect.runPromise(sessions.get(sessionID).pipe(Effect.orElseSucceed(() => null))) if (!session) return await ingest.sync(sessionID, [ { type: "kilo_meta", data: await meta(sessionID) }, @@ -326,7 +327,11 @@ export namespace KiloSessions { }), ) - export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) + export const defaultLayer = layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Config.defaultLayer), + Layer.provide(Session.defaultLayer), + ) export async function enableRemote() { if (remote) return @@ -364,19 +369,24 @@ export namespace KiloSessions { const ids = new Set(Object.keys(statuses)) for (const id of focused) ids.add(id) for (const id of opened) ids.add(id) - const results = await Promise.all( - [...ids].map(async (id) => { - const session = await Session.get(SessionID.make(id)).catch(() => undefined) - if (!session) return undefined - return { - id, - status: statuses[id]?.type ?? "idle", - title: session.title, - parentSessionId: session.parentID, - gitUrl, - gitBranch, - } - }), + const results = await AppRuntime.runPromise( + Session.Service.use((svc) => + Effect.all( + [...ids].map((id) => + svc.get(SessionID.make(id)).pipe( + Effect.map((session) => ({ + id, + status: statuses[id]?.type ?? "idle" as const, + title: session.title, + parentSessionId: session.parentID, + gitUrl, + gitBranch, + })), + Effect.orElseSucceed(() => undefined), + ), + ), + ), + ), ) const sessions = results.filter((r): r is NonNullable => !!r) return { @@ -646,10 +656,16 @@ export namespace KiloSessions { async function fullSync(sessionId: string) { log.info("full sync", { sessionId }) - const session = await Session.get(SessionID.make(sessionId)) const { AppRuntime } = await import("@/effect/app-runtime") - const diffs = await AppRuntime.runPromise( - SessionSummary.Service.use((svc) => svc.diff({ sessionID: SessionID.make(sessionId) })), + const [session, diffs] = await AppRuntime.runPromise( + Effect.gen(function* () { + const sessions = yield* Session.Service + const summary = yield* SessionSummary.Service + return yield* Effect.all([ + sessions.get(SessionID.make(sessionId)), + summary.diff({ sessionID: SessionID.make(sessionId) }), + ]) + }), ) const messages = await Array.fromAsync(MessageV2.stream(SessionID.make(sessionId))) messages.reverse() diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 9666789233b..11c3d87b808 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -13,6 +13,7 @@ import { ModelID, ProviderID } from "@/provider/schema" import * as Log from "@opencode-ai/core/util/log" import z from "zod" import { zodObject } from "@/util/effect-zod" +import { Effect } from "effect" type Provide = typeof import("@/project/with-instance").provide @@ -116,7 +117,10 @@ export namespace RemoteSender { }) async function directoryFor(sid: string): Promise { - const info = await Session.get(SessionID.make(sid)).catch(() => undefined) + const { AppRuntime } = await import("@/effect/app-runtime") + const info = await AppRuntime.runPromise( + Session.Service.use((svc) => svc.get(SessionID.make(sid)).pipe(Effect.orElseSucceed(() => undefined))), + ) return info?.directory ?? options.directory } @@ -199,7 +203,10 @@ export namespace RemoteSender { } async function discoverChildren(parentId: string) { - const childSessions = await Session.children(SessionID.make(parentId)) + const { AppRuntime } = await import("@/effect/app-runtime") + const childSessions = await AppRuntime.runPromise( + Session.Service.use((svc) => svc.children(SessionID.make(parentId))), + ) for (const child of childSessions) { children.set(child.id, parentId) const root = rootOf(child.id) ?? parentId diff --git a/packages/opencode/src/kilocode/permission/allow-everything.ts b/packages/opencode/src/kilocode/permission/allow-everything.ts index 81f4efbda1f..940d077b90a 100644 --- a/packages/opencode/src/kilocode/permission/allow-everything.ts +++ b/packages/opencode/src/kilocode/permission/allow-everything.ts @@ -13,6 +13,7 @@ export namespace AllowEverythingPermission { export function effect(input: Input) { return Effect.gen(function* () { const svc = yield* Permission.Service + const sessions = yield* Session.Service const cfg = yield* Config.Service const bus = yield* Bus.Service const rules: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "allow" }] @@ -20,15 +21,13 @@ export namespace AllowEverythingPermission { if (!input.enable) { if (input.sessionID) { const id = SessionID.make(input.sessionID) - const session = yield* Effect.promise(() => Session.get(id)) - yield* Effect.promise(() => - Session.setPermission({ - sessionID: id, - permission: (session.permission ?? []).filter( - (rule) => !(rule.permission === "*" && rule.pattern === "*" && rule.action === "allow"), - ), - }), - ) + const session = yield* sessions.get(id).pipe(Effect.orDie) + yield* sessions.setPermission({ + sessionID: id, + permission: (session.permission ?? []).filter( + (rule) => !(rule.permission === "*" && rule.pattern === "*" && rule.action === "allow"), + ), + }) yield* svc.allowEverything({ enable: false, sessionID: id }) return true } @@ -41,13 +40,11 @@ export namespace AllowEverythingPermission { if (input.sessionID) { const id = SessionID.make(input.sessionID) - const session = yield* Effect.promise(() => Session.get(id)) - yield* Effect.promise(() => - Session.setPermission({ - sessionID: id, - permission: [...(session.permission ?? []), ...rules], - }), - ) + const session = yield* sessions.get(id).pipe(Effect.orDie) + yield* sessions.setPermission({ + sessionID: id, + permission: [...(session.permission ?? []), ...rules], + }) } if (!input.sessionID) { diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 50f1953af15..e0b1d450705 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -60,6 +60,10 @@ export const PlanFollowupRuntime = { handover(input: LLM.StreamInput, signal: AbortSignal) { return llm().runPromise((svc) => KiloLLM.text(svc.stream(input)).pipe(Effect.orDie), { signal }) }, + async session(run: (svc: Session.Interface) => Effect.Effect) { + const { AppRuntime } = await import("@/effect/app-runtime") + return AppRuntime.runPromise(Session.Service.use(run)) + }, async loop(sessionID: SessionID) { const item = await import("@/session/prompt") const prompt = makeRuntime(item.SessionPrompt.Service, item.SessionPrompt.defaultLayer) @@ -242,7 +246,7 @@ export namespace PlanFollowup { if (text) return text // Fall back to plan file on disk - const session = await Session.get(SessionID.make(input.sessionID)) + const session = await PlanFollowupRuntime.session((svc) => svc.get(SessionID.make(input.sessionID))) const file = Bun.file(Session.plan(session, Instance.current)) const plan = await file.text().catch(() => "") return plan.trim() @@ -265,15 +269,19 @@ export namespace PlanFollowup { agent: input.agent, model: input.model, } - await Session.updateMessage(msg) - await Session.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID: input.sessionID, - type: "text", - text: input.text, - synthetic: input.synthetic ?? true, - } satisfies MessageV2.TextPart) + await PlanFollowupRuntime.session((svc) => + Effect.gen(function* () { + yield* svc.updateMessage(msg) + yield* svc.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: input.sessionID, + type: "text", + text: input.text, + synthetic: input.synthetic ?? true, + } satisfies MessageV2.TextPart) + }), + ) return msg } @@ -338,7 +346,7 @@ export namespace PlanFollowup { const code = await resolveCodeModel({ model: input.model, }) - const session = await Session.get(input.sessionID) + const session = await PlanFollowupRuntime.session((svc) => svc.get(input.sessionID)) const { WithInstance } = await import("@/project/with-instance") await WithInstance.provide({ @@ -348,7 +356,7 @@ export namespace PlanFollowup { // VS Code extension's pendingFollowup gate (30s TTL) is still fresh. The // handover generation below can take tens of seconds and must not block // the SSE event that drives the webview tab switch. - const next = await Session.create({}) + const next = await PlanFollowupRuntime.session((svc) => svc.create({})) const ctl = new AbortController() pending.set(next.id, ctl) const { AppRuntime } = await import("@/effect/app-runtime") @@ -389,16 +397,20 @@ export namespace PlanFollowup { agent: "code", model: code.model, } - await Session.updateMessage(msg) const pid = PartID.ascending() - await Session.updatePart({ - id: pid, - messageID: msg.id, - sessionID: next.id, - type: "text", - text: compose(""), - synthetic: false, - } satisfies MessageV2.TextPart) + await PlanFollowupRuntime.session((svc) => + Effect.gen(function* () { + yield* svc.updateMessage(msg) + yield* svc.updatePart({ + id: pid, + messageID: msg.id, + sessionID: next.id, + type: "text", + text: compose(""), + synthetic: false, + } satisfies MessageV2.TextPart) + }), + ) if (todos.length) { await PlanFollowupRuntime.todo.update({ sessionID: next.id, todos }) @@ -415,14 +427,16 @@ export namespace PlanFollowup { } if (handover) { - await Session.updatePart({ - id: pid, - messageID: msg.id, - sessionID: next.id, - type: "text", - text: compose(handover), - synthetic: false, - } satisfies MessageV2.TextPart) + await PlanFollowupRuntime.session((svc) => + svc.updatePart({ + id: pid, + messageID: msg.id, + sessionID: next.id, + type: "text", + text: compose(handover), + synthetic: false, + } satisfies MessageV2.TextPart), + ) } if (ctl.signal.aborted) { await idle() diff --git a/packages/opencode/src/kilocode/session/fork.ts b/packages/opencode/src/kilocode/session/fork.ts index 8f6a6270f0c..08ec8b34e9b 100644 --- a/packages/opencode/src/kilocode/session/fork.ts +++ b/packages/opencode/src/kilocode/session/fork.ts @@ -1,3 +1,4 @@ +import { Effect } from "effect" import { Session } from "@/session/session" import { MessageV2 } from "@/session/message-v2" import { SessionID, PartID } from "@/session/schema" @@ -22,45 +23,47 @@ function childID(part: MessageV2.Part): string | undefined { * child session references, causing SSE events and permission prompts to bleed * across sessions. */ -export async function remapChildren(sid: SessionID): Promise { - const msgs = await Session.messages({ sessionID: sid }) - const refs: { part: MessageV2.ToolPart; child: string }[] = [] - for (const msg of msgs) { - for (const part of msg.parts) { - const child = childID(part) - if (child) refs.push({ part: part as MessageV2.ToolPart, child }) +export function remapChildren(sid: SessionID): Effect.Effect { + return Effect.gen(function* () { + const sessions = yield* Session.Service + const msgs = yield* sessions.messages({ sessionID: sid }) + const refs: { part: MessageV2.ToolPart; child: string }[] = [] + for (const msg of msgs) { + for (const part of msg.parts) { + const child = childID(part) + if (child) refs.push({ part: part as MessageV2.ToolPart, child }) + } } - } - if (refs.length === 0) return + if (refs.length === 0) return - const remapped = new Map() - for (const ref of refs) { - if (remapped.has(ref.child)) continue - const exists = await Session.get(SessionID.make(ref.child)).catch(() => undefined) - if (!exists) continue - // Session.fork() already calls remapChildren on the forked child, - // so nested subagents are handled recursively without an explicit call here. - const forked = await Session.fork({ sessionID: SessionID.make(ref.child) }) - remapped.set(ref.child, forked.id) - } + const remapped = new Map() + for (const ref of refs) { + if (remapped.has(ref.child)) continue + const exists = yield* sessions.get(SessionID.make(ref.child)).pipe(Effect.orElseSucceed(() => undefined)) + if (!exists) continue + const forked = yield* sessions.fork({ sessionID: SessionID.make(ref.child) }) + yield* remapChildren(forked.id) + remapped.set(ref.child, forked.id) + } - if (remapped.size === 0) return + if (remapped.size === 0) return - for (const ref of refs) { - const replacement = remapped.get(ref.child) - if (!replacement) continue - const meta = (ref.part.state as { metadata?: Record }).metadata - if (!meta) continue - await Session.updatePart({ - ...ref.part, - id: PartID.make(ref.part.id), - sessionID: SessionID.make(ref.part.sessionID), - state: { - ...ref.part.state, - metadata: { ...meta, sessionId: replacement }, - }, - } as MessageV2.ToolPart) - } + for (const ref of refs) { + const replacement = remapped.get(ref.child) + if (!replacement) continue + const meta = (ref.part.state as { metadata?: Record }).metadata + if (!meta) continue + yield* sessions.updatePart({ + ...ref.part, + id: PartID.make(ref.part.id), + sessionID: SessionID.make(ref.part.sessionID), + state: { + ...ref.part.state, + metadata: { ...meta, sessionId: replacement }, + }, + } as MessageV2.ToolPart) + } - log.info("remapped child sessions", { session: sid, count: remapped.size }) + log.info("remapped child sessions", { session: sid, count: remapped.size }) + }) } diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index f2e3aeea7f2..34389450bda 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -1,11 +1,10 @@ // kilocode_change - new file import { remapChildren as _remapChildren } from "./fork" import z from "zod" -import { Schema } from "effect" +import { Effect, Schema } from "effect" import { BusEvent } from "@/bus/bus-event" import { Session } from "@/session/session" import { MessageID, SessionID } from "@/session/schema" -import { makeRuntime } from "@/effect/run-service" import { fn } from "@/util/fn" import { Database, eq, and, gte, isNull, desc, like, inArray, lt, or } from "@/storage/db" import type { SQL } from "@/storage/db" @@ -397,9 +396,14 @@ export namespace KiloSession { export const kiloSessionFork = fn( z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }), async (input) => { - const { runPromise } = makeRuntime(Session.Service, Session.defaultLayer) - const session = await runPromise((svc) => svc.fork(input)) - await KiloSession.remapChildren(session.id) - return session + const { AppRuntime } = await import("@/effect/app-runtime") + return AppRuntime.runPromise( + Effect.gen(function* () { + const sessions = yield* Session.Service + const session = yield* sessions.fork(input) + yield* KiloSession.remapChildren(session.id) + return session + }), + ) }, ) diff --git a/packages/opencode/src/kilocode/tool/notebook.ts b/packages/opencode/src/kilocode/tool/notebook.ts new file mode 100644 index 00000000000..51d1006f7a2 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/notebook.ts @@ -0,0 +1,46 @@ +import * as path from "path" +import { Readable } from "stream" +import * as Encoding from "../encoding" + +type ObjectValue = Record + +const object = (value: unknown): value is ObjectValue => typeof value === "object" && value !== null && !Array.isArray(value) + +const parse = (text: string): unknown => { + try { + return JSON.parse(text) + } catch { + return undefined + } +} + +const source = (value: unknown): string | undefined => { + if (typeof value === "string") return value + if (!Array.isArray(value) || !value.every((line) => typeof line === "string")) return undefined + return value.join("") +} + +const render = (kind: "markdown" | "code", text: string) => { + const body = text.endsWith("\n") ? text : `${text}\n` + return `<${kind}_cell>\n${body}` +} + +export async function open(filepath: string): Promise { + if (path.extname(filepath).toLowerCase() !== ".ipynb") return undefined + + const raw = (await Encoding.read(filepath)).text + const data = parse(raw) + if (!object(data) || !Array.isArray(data.cells)) return Readable.from([raw]) + + const cells: string[] = [] + for (const cell of data.cells) { + if (!object(cell)) continue + if (cell.cell_type !== "markdown" && cell.cell_type !== "code") continue + + const text = source(cell.source) + if (text === undefined) continue + cells.push(render(cell.cell_type, text)) + } + + return Readable.from([cells.length ? cells.join("\n\n") : "(Notebook contains no markdown or code cell content.)"]) +} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 3b0023bc029..d30dfc91fca 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -29,12 +29,9 @@ import { ModelID, ProviderID } from "@/provider/schema" import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" -// kilocode_change start - legacy promise helpers + kilocode extensions -import { makeRuntime } from "@/effect/run-service" +// kilocode_change start - Kilo session behavior extensions import { BackgroundProcess } from "@/kilocode/background-process" import { KiloSession, kiloSessionFork } from "@/kilocode/session" -import { fn } from "@/util/fn" -import { z } from "zod" // kilocode_change end import { Effect, Layer, Option, Context, Schema, Types } from "effect" import { zod } from "@/util/effect-zod" @@ -949,69 +946,7 @@ export function* listGlobal(input?: { } // kilocode_change end -// kilocode_change start - keep legacy promise helpers for Kilo callsites -const { runPromise } = makeRuntime(Service, defaultLayer) - -const decodeCreate = Schema.decodeUnknownSync(CreateInput) -const decodeGet = Schema.decodeUnknownSync(GetInput) -const decodeSetTitle = Schema.decodeUnknownSync(SetTitleInput) -const decodeSetArchived = Schema.decodeUnknownSync(SetArchivedInput) -const decodeSetPermission = Schema.decodeUnknownSync(SetPermissionInput) -const decodeSetRevert = Schema.decodeUnknownSync(SetRevertInput) -const decodeMessages = Schema.decodeUnknownSync(MessagesInput) -const decodeChildren = Schema.decodeUnknownSync(ChildrenInput) -const decodeRemove = Schema.decodeUnknownSync(RemoveInput) - -export const create = (input?: CreateInput) => runPromise((svc) => svc.create(decodeCreate(input) as CreateInput)) +// kilocode_change - preserve Kilo recursive fork/remap behavior without a Session service-local Promise runtime export const fork = kiloSessionFork -export const get = (id: SessionID) => runPromise((svc) => svc.get(decodeGet(id))) -export const setTitle = (input: { sessionID: SessionID; title: string }) => - runPromise((svc) => svc.setTitle(decodeSetTitle(input))) -export const setArchived = (input: { sessionID: SessionID; time?: number }) => - runPromise((svc) => svc.setArchived(decodeSetArchived(input))) -export const setPermission = (input: { sessionID: SessionID; permission: Permission.Ruleset }) => - runPromise((svc) => - svc.setPermission(decodeSetPermission(input) as { sessionID: SessionID; permission: Permission.Ruleset }), - ) -export const setRevert = (input: { sessionID: SessionID; revert?: Info["revert"]; summary?: Info["summary"] }) => { - const parsed = decodeSetRevert(input) as { sessionID: SessionID; revert?: Info["revert"]; summary?: Info["summary"] } - return runPromise((svc) => - svc.setRevert({ sessionID: parsed.sessionID, revert: parsed.revert, summary: parsed.summary }), - ) -} -export const messages = (input: { sessionID: SessionID; limit?: number }) => - runPromise((svc) => svc.messages(decodeMessages(input))) -export const children = (id: SessionID) => runPromise((svc) => svc.children(decodeChildren(id))) -export const remove = (id: SessionID) => runPromise((svc) => svc.remove(decodeRemove(id))) -export async function updateMessage(msg: T): Promise { - MessageV2.Info.zod.parse(msg) // kilocode_change - return runPromise((svc) => svc.updateMessage(msg)) -} - -export const removeMessage = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), (input) => - runPromise((svc) => svc.removeMessage(input)), -) - -export const removePart = fn( - z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod }), - (input) => runPromise((svc) => svc.removePart(input)), -) - -export async function updatePart(part: T): Promise { - MessageV2.Part.zod.parse(part) // kilocode_change - return runPromise((svc) => svc.updatePart(part)) -} - -export const updatePartDelta = fn( - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - field: z.string(), - delta: z.string(), - }), - (input) => runPromise((svc) => svc.updatePartDelta(input)), -) -// kilocode_change end export * as Session from "./session" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 67ad87e2bc9..0bb7f1dcb0a 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -15,6 +15,7 @@ import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" // kilocode_change start import * as Encoding from "../kilocode/encoding" import * as TextStream from "../kilocode/text-stream" +import * as Notebook from "../kilocode/tool/notebook" import * as Docx from "../kilocode/tool/read-docx" // kilocode_change end @@ -362,6 +363,8 @@ export const ReadTool = Tool.define( // iconv. The body otherwise matches upstream. export async function lines(filepath: string, opts: { limit: number; offset: number }) { if (Docx.accepts(filepath)) return readLines(await Docx.open(filepath), opts) // kilocode_change + const extracted = await Notebook.open(filepath) // kilocode_change - extract readable notebook cells before paging + if (extracted) return readLines(extracted, opts) // kilocode_change return TextStream.withFallback(filepath, (stream) => readLines(stream, opts)) } diff --git a/packages/opencode/src/tool/recall.ts b/packages/opencode/src/tool/recall.ts index 15502b6a71e..264a2ee786c 100644 --- a/packages/opencode/src/tool/recall.ts +++ b/packages/opencode/src/tool/recall.ts @@ -7,6 +7,8 @@ import { Instance } from "../project/instance" import { Locale } from "../util/locale" import { Filesystem } from "../util/filesystem" // kilocode_change import { WorktreeFamily } from "../kilocode/worktree-family" // kilocode_change +import { Session } from "../session/session" // kilocode_change +import { SessionID } from "../session/schema" // kilocode_change import DESCRIPTION from "./recall.txt" const Parameters = Schema.Struct({ @@ -28,6 +30,7 @@ export const RecallTool = Tool.define( "kilo_local_recall", Effect.gen(function* () { const git = yield* Git.Service + const sessions = yield* Session.Service // kilocode_change return { description: DESCRIPTION, parameters: Parameters, @@ -37,7 +40,7 @@ export const RecallTool = Tool.define( if (params.mode === "search") { return yield* Effect.promise(() => search(params, ctx, bridge, git)) } - return yield* Effect.promise(() => read(params, ctx, bridge, git)) + return yield* Effect.promise(() => read(params, ctx, bridge, git, sessions)) }).pipe(Effect.orDie), } }), @@ -106,14 +109,18 @@ async function search( } } -async function read(params: { sessionID?: string }, ctx: Tool.Context, bridge: EffectBridge.Shape, git: Git.Interface) { +async function read( + params: { sessionID?: string }, + ctx: Tool.Context, + bridge: EffectBridge.Shape, + git: Git.Interface, + sessions: Session.Interface, +) { if (!params.sessionID) { throw new Error("The 'sessionID' parameter is required when mode is 'read'") } - const { Session } = await import("../session/session") // kilocode_change - const { SessionID } = await import("../session/schema") // kilocode_change - const session = await Session.get(SessionID.make(params.sessionID)).catch(() => { + const session = await bridge.promise(sessions.get(SessionID.make(params.sessionID))).catch(() => { throw new Error(`Session "${params.sessionID}" not found. Use search mode first to find valid session IDs.`) }) const dirs = await bridge.promise(WorktreeFamily.list().pipe(Effect.provideService(Git.Service, git))) // kilocode_change @@ -140,7 +147,7 @@ async function read(params: { sessionID?: string }, ctx: Tool.Context, bridge: E }) } - const msgs = await Session.messages({ sessionID: session.id }) + const msgs = await bridge.promise(sessions.messages({ sessionID: session.id })) const lines: string[] = [ `# Session: ${session.title}`, `Directory: ${session.directory}`, diff --git a/packages/opencode/test/kilocode/kilo-sessions.test.ts b/packages/opencode/test/kilocode/kilo-sessions.test.ts index fda59f04646..64938ff002d 100644 --- a/packages/opencode/test/kilocode/kilo-sessions.test.ts +++ b/packages/opencode/test/kilocode/kilo-sessions.test.ts @@ -16,7 +16,11 @@ const it = testEffect(CrossSpawnSpawner.defaultLayer) function layer(overrides: Partial = {}) { 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, ) } diff --git a/packages/opencode/test/kilocode/plan-exit-detection.test.ts b/packages/opencode/test/kilocode/plan-exit-detection.test.ts index 5e6371b71b2..307e6cbb89e 100644 --- a/packages/opencode/test/kilocode/plan-exit-detection.test.ts +++ b/packages/opencode/test/kilocode/plan-exit-detection.test.ts @@ -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[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[0]) => + Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))), + updateMessage: (msg: T) => + Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))), + updatePart: (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; 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) diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index ea6f55eacb0..222d48ea6ef 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -46,6 +46,19 @@ const todo = { }, } +const store = { + create: (input?: Parameters[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[0]) => + Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))), + updateMessage: (msg: T) => + Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))), + updatePart: (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; 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", () => { diff --git a/packages/opencode/test/kilocode/read-notebook.test.ts b/packages/opencode/test/kilocode/read-notebook.test.ts new file mode 100644 index 00000000000..e4a6fa2da1e --- /dev/null +++ b/packages/opencode/test/kilocode/read-notebook.test.ts @@ -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) { + 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("") + expect(result.output).toContain("# Analysis") + expect(result.output).toContain("") + 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("") + }), + ) + + 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("") + }), + ) + + 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) + }), + ) +}) diff --git a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts index d56710099fe..2842095b3b7 100644 --- a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts +++ b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts @@ -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, diff --git a/packages/opencode/test/kilocode/session-fork-remap.test.ts b/packages/opencode/test/kilocode/session-fork-remap.test.ts index fa92a1a74e5..7376c09f3f6 100644 --- a/packages/opencode/test/kilocode/session-fork-remap.test.ts +++ b/packages/opencode/test/kilocode/session-fork-remap.test.ts @@ -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[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[0]) => + Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))), + updateMessage: (msg: T) => + Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))), + updatePart: (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") diff --git a/packages/opencode/test/kilocode/session-list.test.ts b/packages/opencode/test/kilocode/session-list.test.ts index 040e0d4bc68..07d1cf95e26 100644 --- a/packages/opencode/test/kilocode/session-list.test.ts +++ b/packages/opencode/test/kilocode/session-list.test.ts @@ -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) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 9c7b419851a..c92b6ca0821 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -17,6 +17,15 @@ import { provideInstance, tmpdir } from "../fixture/fixture" Log.init({ print: false }) +const sessions = { + create: (input?: Parameters[0]) => + Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))), + messages: (input: Parameters[0]) => + Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))), + updateMessage: (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() }) diff --git a/packages/opencode/test/kilocode/snapshot-freeze-repro.test.ts b/packages/opencode/test/kilocode/snapshot-freeze-repro.test.ts index 68b1ceb72b5..4dc29a388e9 100644 --- a/packages/opencode/test/kilocode/snapshot-freeze-repro.test.ts +++ b/packages/opencode/test/kilocode/snapshot-freeze-repro.test.ts @@ -25,8 +25,10 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) -function run(body: (snapshot: Snapshot.Interface) => Effect.Effect) { - return Effect.runPromise(Snapshot.Service.use(body).pipe(Effect.provide(Snapshot.defaultLayer))) +function run(body: (snapshot: Snapshot.Interface) => Effect.Effect) { + 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() diff --git a/packages/opencode/test/server/experimental-session-list.test.ts b/packages/opencode/test/server/experimental-session-list.test.ts index ed355eb2df8..0292057367c 100644 --- a/packages/opencode/test/server/experimental-session-list.test.ts +++ b/packages/opencode/test/server/experimental-session-list.test.ts @@ -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 diff --git a/packages/opencode/test/tool/recall.test.ts b/packages/opencode/test/tool/recall.test.ts index aa6ef3f64d4..50c08882e9b 100644 --- a/packages/opencode/test/tool/recall.test.ts +++ b/packages/opencode/test/tool/recall.test.ts @@ -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({ diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts index 49181f613f4..578385d32a0 100644 --- a/script/check-opencode-promise-facades.ts +++ b/script/check-opencode-promise-facades.ts @@ -25,7 +25,6 @@ const allow: Record = { "installation/index.ts": "existing installation facade outside #10655", "question/index.ts": "transitional facade deferred for upstream reconciliation in #10655", "session/compaction.ts": "existing compaction facade outside #10655", - "session/session.ts": "transitional facade tracked by #10655", "sync/index.ts": "sync event runtime boundary", } @@ -37,7 +36,6 @@ const testAllow: Record = { "kilocode/config-resilience.test.ts": { count: 4, reason: "existing runtime integration test" }, "kilocode/config-validation.test.ts": { count: 2, reason: "existing runtime integration test" }, "kilocode/plan-followup.test.ts": { count: 7, reason: "existing runtime integration test" }, - "kilocode/session-list.test.ts": { count: 2, reason: "existing runtime integration test" }, "kilocode/session/platform-attribution.test.ts": { count: 5, reason: "existing runtime integration test" }, "kilocode/session/session.test.ts": { count: 4, reason: "existing runtime integration test" }, "mcp/headers.test.ts": { count: 4, reason: "existing runtime integration test" }, @@ -86,7 +84,9 @@ const testInvalid = testHits.filter((hit) => !testAllow[hit.file]) const testDrift = Object.entries(testAllow).flatMap(([file, entry]) => { const count = testHits.filter((hit) => hit.file === file).length if (count === entry.count) return [] - return [` packages/opencode/test/${file}: expected ${entry.count} classified reference(s), found ${count} (${entry.reason})`] + return [ + ` packages/opencode/test/${file}: expected ${entry.count} classified reference(s), found ${count} (${entry.reason})`, + ] }) if (invalid.length > 0 || drift.length > 0 || testInvalid.length > 0 || testDrift.length > 0) {