Merge remote-tracking branch 'origin/main' into feature-read-docx-extraction

# Conflicts:
#	packages/opencode/src/tool/read.ts
This commit is contained in:
marius-kilocode
2026-05-29 16:43:32 +02:00
23 changed files with 598 additions and 299 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Read Jupyter notebooks as ordered markdown and code cell content instead of raw notebook payloads.
@@ -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<typeof r> => !!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()
@@ -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<string> {
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
@@ -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) {
+43 -29
View File
@@ -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<A, E>(run: (svc: Session.Interface) => Effect.Effect<A, E>) {
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()
+39 -36
View File
@@ -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<void> {
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<void, Session.NotFound, Session.Service> {
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<string, SessionID>()
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<string, SessionID>()
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<string, unknown> }).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<string, unknown> }).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 })
})
}
@@ -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
}),
)
},
)
@@ -0,0 +1,46 @@
import * as path from "path"
import { Readable } from "stream"
import * as Encoding from "../encoding"
type ObjectValue = Record<string, unknown>
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}</${kind}_cell>`
}
export async function open(filepath: string): Promise<Readable | undefined> {
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.)"])
}
+2 -67
View File
@@ -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<T extends MessageV2.Info>(msg: T): Promise<T> {
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<T extends MessageV2.Part>(part: T): Promise<T> {
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"
+3
View File
@@ -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))
}
+13 -6
View File
@@ -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}`,
@@ -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)
}),
)
})
@@ -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({
+3 -3
View File
@@ -25,7 +25,6 @@ const allow: Record<string, string> = {
"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<string, { count: number; reason: string }> = {
"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) {