diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 20cc36fdb7..710846238c 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -182,7 +182,8 @@ export namespace KiloSessions { const questions = (await Question.list()).filter((q) => q.sessionID === sessionID) if (questions.length > 0) return "question" - const status = await SessionStatus.get(SessionID.make(sessionID)) + const { AppRuntime } = await import("@/effect/app-runtime") + const status = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(SessionID.make(sessionID)))) if (status.type === "offline") return "retry" return status.type } @@ -344,7 +345,8 @@ export namespace KiloSessions { getGitUrl().catch(() => undefined), Vcs.branch().catch(() => undefined), ]) - const statusMap = await SessionStatus.list() + const { AppRuntime } = await import("@/effect/app-runtime") + const statusMap = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())) const statuses: Record = Object.fromEntries(statusMap) const ids = new Set(Object.keys(statuses)) for (const id of focused) ids.add(id) diff --git a/packages/opencode/src/kilocode/commit-message/generate.ts b/packages/opencode/src/kilocode/commit-message/generate.ts index 3756369258..6f8ab2b71e 100644 --- a/packages/opencode/src/kilocode/commit-message/generate.ts +++ b/packages/opencode/src/kilocode/commit-message/generate.ts @@ -1,12 +1,27 @@ import { Provider } from "@/provider/provider" import { LLM } from "@/session/llm" +import { KiloLLM } from "@/kilocode/session/llm" import { Agent } from "@/agent/agent" +import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" import * as Log from "@opencode-ai/core/util/log" import type { CommitMessageRequest, CommitMessageResponse, GitContext } from "./types" import { getGitContext } from "./git-context" const log = Log.create({ service: "commit-message" }) +export const CommitMessageRuntime = { + generate(input: LLM.StreamInput, signal: AbortSignal) { + // runPromise is needed until generateCommitMessage() uses Effect + return AppRuntime.runPromise( + LLM.Service.use((svc) => KiloLLM.text(svc.stream(input)).pipe(Effect.orDie)), + { + signal, + }, + ) + }, +} + const SYSTEM_PROMPT = `You are an expert Git commit message generator that creates conventional commit messages based on staged changes. Analyze the provided git diff output and generate an appropriate conventional commit message following the specification. ## Conventional Commits Format @@ -150,44 +165,37 @@ export async function generateCommitMessage(request: CommitMessageRequest): Prom const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) try { - const stream = await LLM.stream({ - agent, - user: { - id: "commit-message", + const result = await CommitMessageRuntime.generate( + { + agent, + user: { + id: "commit-message", + sessionID: "commit-message", + role: "user", + model: { + providerID: model.providerID, + modelID: model.id, + }, + time: { + created: Date.now(), + completed: Date.now(), + }, + } as any, + tools: {}, + model, + small: true, + messages: [ + { + role: "user" as const, + content: userMessage, + }, + ], sessionID: "commit-message", - role: "user", - model: { - providerID: model.providerID, - modelID: model.id, - }, - time: { - created: Date.now(), - completed: Date.now(), - }, - } as any, - tools: {}, - model, - small: true, - messages: [ - { - role: "user" as const, - content: userMessage, - }, - ], - abort: controller.signal, - sessionID: "commit-message", - system: [], - retries: 3, - }) - - // Consume the stream explicitly so that stream-level errors surface - // immediately instead of leaving the .text promise hanging (issue #7345). - // With some providers/versions of the Vercel AI SDK, `await stream.text` - // never resolves when the underlying stream errors out early. - let result = "" - for await (const chunk of stream.textStream) { - result += chunk - } + system: [], + retries: 3, + }, + controller.signal, + ) log.info("generated", { message: result }) return { message: clean(result) } diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 8045ee881b..50f1953af1 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -12,10 +12,12 @@ import { Question } from "@/question" import { Session } from "@/session/session" import { SessionID, MessageID, PartID } from "@/session/schema" import { LLM } from "@/session/llm" +import { KiloLLM } from "@/kilocode/session/llm" import { MessageV2 } from "@/session/message-v2" import { SessionStatus } from "@/session/status" import { Todo } from "@/session/todo" import { makeRuntime } from "@/effect/run-service" +import { Effect } from "effect" import * as Log from "@opencode-ai/core/util/log" import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" import { lazy } from "@/util/lazy" @@ -26,6 +28,7 @@ const agents = lazy(() => makeRuntime(Agent.Service, Agent.defaultLayer)) const providers = lazy(() => makeRuntime(Provider.Service, Provider.defaultLayer)) const questions = lazy(() => makeRuntime(Question.Service, Question.defaultLayer)) const todo = lazy(() => makeRuntime(Todo.Service, Todo.defaultLayer)) +const llm = lazy(() => makeRuntime(LLM.Service, LLM.defaultLayer)) const pending = new Map() export const PlanFollowupRuntime = { @@ -54,6 +57,9 @@ export const PlanFollowupRuntime = { return todo().runPromise((svc) => svc.update(input)) }, }, + handover(input: LLM.StreamInput, signal: AbortSignal) { + return llm().runPromise((svc) => KiloLLM.text(svc.stream(input)).pipe(Effect.orDie), { signal }) + }, async loop(sessionID: SessionID) { const item = await import("@/session/prompt") const prompt = makeRuntime(item.SessionPrompt.Service, item.SessionPrompt.defaultLayer) @@ -123,31 +129,31 @@ export async function generateHandover(input: { model: input.model, } - const stream = await LLM.stream({ - agent: entry ?? { - name: "compaction", - mode: "subagent", - permission: [], - options: {}, - }, - user: userMsg, - tools: {}, - model, - small: true, - messages: [ - ...(await MessageV2.toModelMessages(input.messages, model)), - { - role: "user" as const, - content: HANDOVER_PROMPT, + const result = await PlanFollowupRuntime.handover( + { + agent: entry ?? { + name: "compaction", + mode: "subagent", + permission: [], + options: {}, }, - ], - abort: input.abort ? AbortSignal.any([input.abort, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000), - sessionID, - system: [], - retries: 1, - }) - - const result = await stream.text + user: userMsg, + tools: {}, + model, + small: true, + messages: [ + ...(await MessageV2.toModelMessages(input.messages, model)), + { + role: "user" as const, + content: HANDOVER_PROMPT, + }, + ], + sessionID, + system: [], + retries: 1, + }, + input.abort ? AbortSignal.any([input.abort, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000), + ) return result.trim() } catch (error) { if (input.abort?.aborted) return "" @@ -345,11 +351,12 @@ export namespace PlanFollowup { const next = await Session.create({}) const ctl = new AbortController() pending.set(next.id, ctl) - await SessionStatus.set(next.id, { type: "busy" }) + const { AppRuntime } = await import("@/effect/app-runtime") + await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.set(next.id, { type: "busy" }))) await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id }) const idle = () => - SessionStatus.set(next.id, { type: "idle" }).catch((err) => { + AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.set(next.id, { type: "idle" }))).catch((err) => { log.warn("failed to clear follow-up busy status", { sessionID: next.id, err }) }) diff --git a/packages/opencode/src/kilocode/session/llm.ts b/packages/opencode/src/kilocode/session/llm.ts index ce58b4681e..ac7ab65451 100644 --- a/packages/opencode/src/kilocode/session/llm.ts +++ b/packages/opencode/src/kilocode/session/llm.ts @@ -1,5 +1,8 @@ import type { ModelMessage } from "ai" +import { Effect } from "effect" +import * as Stream from "effect/Stream" import type { Provider } from "@/provider/provider" +import type { Event } from "@/session/llm" import { Token } from "@/util/token" // Token.estimate consistently under-counts by ~15-30% vs. actual provider tokenizers. @@ -9,6 +12,19 @@ const SAFETY = 2048 const MIN_OUTPUT = 1024 export namespace KiloLLM { + // Preserve error and abort events while collecting text so Kilo callers can detect failed generations. + export function text(stream: Stream.Stream) { + return stream.pipe( + Stream.mapEffect((event) => { + if (event.type === "error") return Effect.fail(event.error) + if (event.type === "abort") return Effect.fail(new DOMException("Aborted", "AbortError")) + if (event.type !== "text-delta") return Effect.succeed("") + return Effect.succeed(event.text) + }), + Stream.mkString, + ) + } + /** * Caps `maxOutputTokens` to fit within the model's context window after * accounting for the actual estimated input tokens (messages + tool schemas). diff --git a/packages/opencode/src/kilocode/suggestion/tool.ts b/packages/opencode/src/kilocode/suggestion/tool.ts index f57b19b5a0..dfd13bccf9 100644 --- a/packages/opencode/src/kilocode/suggestion/tool.ts +++ b/packages/opencode/src/kilocode/suggestion/tool.ts @@ -61,57 +61,59 @@ export function resolvePrompt(prompt: string, commands: Command.Interface) { }) } -export const SuggestTool = Tool.define( +export const SuggestTool = Tool.define( "suggest", Effect.gen(function* () { const commands = yield* Command.Service + const status = yield* SessionStatus.Service return { description: DESCRIPTION, parameters: Params, execute: (params, ctx) => Effect.gen(function* () { - const action = yield* Effect.promise(async () => { - const promise = Suggestion.show({ - sessionID: ctx.sessionID, - text: params.suggest, - actions: params.actions.map((a) => ({ ...a })), - blocking: false, // render above an active input; VS Code does the same - tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + const promise = Suggestion.show({ + sessionID: ctx.sessionID, + text: params.suggest, + actions: params.actions.map((a) => ({ ...a })), + blocking: false, // render above an active input; VS Code does the same + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + + const listener = () => + Suggestion.list().then((items: Suggestion.Request[]) => { + const match = items.find((item: Suggestion.Request) => item.tool?.callID === ctx.callID) + if (match) return Suggestion.dismiss(match.id) }) + ctx.abort.addEventListener("abort", listener, { once: true }) - const listener = () => - Suggestion.list().then((items: Suggestion.Request[]) => { - const match = items.find((item: Suggestion.Request) => item.tool?.callID === ctx.callID) - if (match) return Suggestion.dismiss(match.id) - }) - ctx.abort.addEventListener("abort", listener, { once: true }) + // Mark the session as idle while waiting for user interaction so the + // session doesn't appear stuck/busy. The loop will set it back to busy + // when the suggestion resolves and processing continues. + yield* status + .set(SessionID.make(ctx.sessionID), { type: "idle" }) + .pipe(Effect.catchCause((cause) => Effect.sync(() => log.warn("failed to set idle status", { cause })))) - // Mark the session as idle while waiting for user interaction so the - // session doesn't appear stuck/busy. The loop will set it back to busy - // when the suggestion resolves and processing continues. - await SessionStatus.set(SessionID.make(ctx.sessionID), { type: "idle" }).catch((err) => { - log.warn("failed to set idle status", { err }) - }) - - const action = await promise + const action = yield* Effect.promise(() => + promise .catch((error) => { if (error instanceof Suggestion.DismissedError) return undefined throw error }) .finally(() => { ctx.abort.removeEventListener("abort", listener) - }) + }), + ) - // Restore busy immediately on accept so the session doesn't flash idle - // while the follow-up response is being generated. The next runLoop - // iteration sets busy too, but not until after the stream finalizes. - if (action) { - await SessionStatus.set(SessionID.make(ctx.sessionID), { type: "busy" }).catch((err) => { - log.warn("failed to restore busy status", { err }) - }) - } - return action - }) + // Restore busy immediately on accept so the session doesn't flash idle + // while the follow-up response is being generated. The next runLoop + // iteration sets busy too, but not until after the stream finalizes. + if (action) { + yield* status + .set(SessionID.make(ctx.sessionID), { type: "busy" }) + .pipe( + Effect.catchCause((cause) => Effect.sync(() => log.warn("failed to restore busy status", { cause }))), + ) + } if (!action) { const metadata: Meta = { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index cf301aaba3..6bedeed1dc 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -30,7 +30,6 @@ import { HEADER_TASKID, } from "@kilocode/kilo-gateway" import { Identity } from "@kilocode/kilo-telemetry" -import { makeRuntime } from "@/effect/run-service" import { KiloSession } from "@/kilocode/session" import { KiloLLM } from "@/kilocode/session/llm" // kilocode_change end @@ -71,7 +70,6 @@ export type Event = Result["fullStream"] extends AsyncIterable ? T : ne export interface Interface { readonly stream: (input: StreamInput) => Stream.Stream - readonly raw: (input: StreamRequest) => Effect.Effect // kilocode_change - raw streamText result for Kilo helpers } export class Service extends Context.Service()("@opencode/LLM") {} @@ -481,8 +479,7 @@ const live: Layer.Layer< ), ) - // kilocode_change - expose raw streamText result for Kilo helpers; Effect.orDie collapses AuthError into a defect - return Service.of({ stream, raw: (input) => run(input).pipe(Effect.orDie) }) + return Service.of({ stream }) }), ) @@ -497,13 +494,6 @@ export const defaultLayer = Layer.suspend(() => ), ) -// kilocode_change start - keep raw async stream wrapper for Kilo callsites during Effect migration -const runtime = makeRuntime(Service, defaultLayer) -export async function stream(input: StreamRequest) { - return runtime.runPromise((svc) => svc.raw(input), { signal: input.abort }) -} -// kilocode_change end - function resolveTools(input: Pick) { const disabled = Permission.disabled( Object.keys(input.tools), diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 3c159e2743..d1c291289a 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -3,7 +3,6 @@ import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" import { QuestionID } from "@/question/schema" // kilocode_change -import { makeRuntime } from "@/effect/run-service" // kilocode_change import { zod } from "@/util/effect-zod" import { NonNegativeInt, withStatics } from "@/util/schema" import { Effect, Layer, Context, Schema } from "effect" @@ -94,12 +93,4 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) -// kilocode_change start - legacy promise helpers for Kilo callsites -const { runPromise } = makeRuntime(Service, defaultLayer) - -export const list = () => runPromise((svc) => svc.list()) -export const get = (sessionID: SessionID) => runPromise((svc) => svc.get(sessionID)) -export const set = (sessionID: SessionID, status: Info) => runPromise((svc) => svc.set(sessionID, status)) -// kilocode_change end - export * as SessionStatus from "./status" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index f3fcb76ff4..2da781d48d 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -7,7 +7,6 @@ import { withStatics } from "@/util/schema" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" -import { makeRuntime } from "@/effect/run-service" // kilocode_change import { InstanceState } from "@/effect/instance-state" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" @@ -285,13 +284,6 @@ export const defaultLayer = layer.pipe( Layer.provide(Global.layer), ) -// kilocode_change start - legacy promise helpers for Kilo callsites -const { runPromise } = makeRuntime(Service, defaultLayer) -export const all = () => runPromise((svc) => svc.all()) -export const get = (name: string) => runPromise((svc) => svc.get(name)) -export const dirs = () => runPromise((svc) => svc.dirs()) -// kilocode_change end - export function fmt(list: Info[], opts: { verbose: boolean }) { if (list.length === 0) return "No skills are currently available." if (opts.verbose) { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 63326ba95d..f3b8cfe85b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -53,6 +53,7 @@ import { Agent } from "../agent/agent" import { Git } from "../git" // kilocode_change import { Skill } from "../skill" import { Permission } from "@/permission" +import { SessionStatus } from "@/session/status" // kilocode_change const log = Log.create({ service: "tool.registry" }) @@ -97,6 +98,7 @@ export const layer: Layer.Layer< | Truncate.Service | Command.Service // kilocode_change | Git.Service // kilocode_change + | SessionStatus.Service // kilocode_change > = Layer.effect( Service, Effect.gen(function* () { @@ -378,6 +380,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Truncate.defaultLayer), Layer.provide(Command.defaultLayer), // kilocode_change Layer.provide(Git.defaultLayer), // kilocode_change + Layer.provide(SessionStatus.defaultLayer), // kilocode_change ), ) // kilocode_change start diff --git a/packages/opencode/test/kilocode/builtin-skills.test.ts b/packages/opencode/test/kilocode/builtin-skills.test.ts index 53cf46e1b7..fd3672ad70 100644 --- a/packages/opencode/test/kilocode/builtin-skills.test.ts +++ b/packages/opencode/test/kilocode/builtin-skills.test.ts @@ -1,21 +1,20 @@ -import { afterEach, test, expect } from "bun:test" +import { expect } from "bun:test" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import path from "path" import { Skill } from "../../src/skill" -import { WithInstance } from "../../src/project/with-instance" import { BUILTIN_SKILLS } from "../../src/kilocode/skills/builtin" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -afterEach(async () => { - await disposeAllInstances() -}) +const it = testEffect(Layer.mergeAll(Skill.defaultLayer, CrossSpawnSpawner.defaultLayer)) -test("built-in skills are present in empty project", async () => { - await using tmp = await tmpdir({ git: true }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() +it.instance( + "built-in skills are present in empty project", + () => + Effect.gen(function* () { + const skill = yield* Skill.Service + const skills = yield* skill.all() for (const builtin of BUILTIN_SKILLS) { const found = skills.find((s) => s.name === builtin.name) expect(found).toBeDefined() @@ -23,33 +22,34 @@ test("built-in skills are present in empty project", async () => { expect(found!.description).toBe(builtin.description) expect(found!.content.length).toBeGreaterThan(0) } - }, - }) -}) + }), + { git: true }, +) -test("built-in skill has correct metadata", async () => { - await using tmp = await tmpdir({ git: true }) +it.instance( + "built-in skill has correct metadata", + () => + Effect.gen(function* () { + const skill = yield* Skill.Service + const item = yield* skill.get("kilo-config") + expect(item).toBeDefined() + expect(item!.name).toBe("kilo-config") + expect(item!.location).toBe(Skill.BUILTIN_LOCATION) + expect(item!.content).toContain("kilo") + }), + { git: true }, +) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const skill = await Skill.get("kilo-config") - expect(skill).toBeDefined() - expect(skill!.name).toBe("kilo-config") - expect(skill!.location).toBe(Skill.BUILTIN_LOCATION) - expect(skill!.content).toContain("kilo") - }, - }) -}) - -test("user skill overrides built-in with same name", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".kilo", "skill", "kilo-config") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- +it.instance( + "user skill overrides built-in with same name", + () => + Effect.gen(function* () { + const instance = yield* TestInstance + const dir = path.join(instance.directory, ".kilo", "skill", "kilo-config") + yield* Effect.promise(() => + Bun.write( + path.join(dir, "SKILL.md"), + `--- name: kilo-config description: User override of kilo-config. --- @@ -58,18 +58,15 @@ description: User override of kilo-config. User-provided content. `, + ), ) - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const skill = await Skill.get("kilo-config") - expect(skill).toBeDefined() - expect(skill!.description).toBe("User override of kilo-config.") - expect(skill!.location).not.toBe(Skill.BUILTIN_LOCATION) - expect(skill!.location).toContain(path.join("skill", "kilo-config", "SKILL.md")) - }, - }) -}) + const skill = yield* Skill.Service + const item = yield* skill.get("kilo-config") + expect(item).toBeDefined() + expect(item!.description).toBe("User override of kilo-config.") + expect(item!.location).not.toBe(Skill.BUILTIN_LOCATION) + expect(item!.location).toContain(path.join("skill", "kilo-config", "SKILL.md")) + }), + { git: true }, +) diff --git a/packages/opencode/test/kilocode/commit-message/generate.test.ts b/packages/opencode/test/kilocode/commit-message/generate.test.ts index 19cf580bd4..8b030061b0 100644 --- a/packages/opencode/test/kilocode/commit-message/generate.test.ts +++ b/packages/opencode/test/kilocode/commit-message/generate.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, mock, beforeEach } from "bun:test" +import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test" import type { GitContext } from "@/kilocode/commit-message/types" // Mock dependencies before importing the module under test. @@ -8,7 +8,6 @@ import type { GitContext } from "@/kilocode/commit-message/types" const realLog = await import("@opencode-ai/core/util/log") const realProvider = await import("@/provider/provider") -const realLLM = await import("@/session/llm") const realAgent = await import("@/agent/agent") const realGitContext = await import("@/kilocode/commit-message/git-context") @@ -50,19 +49,6 @@ mock.module("@/provider/provider", () => ({ }, })) -mock.module("@/session/llm", () => ({ - ...realLLM, - LLM: { - ...realLLM.LLM, - stream: async () => ({ - textStream: (async function* () { - yield mockStreamText - })(), - text: Promise.resolve(mockStreamText), - }), - }, -})) - mock.module("@/agent/agent", () => ({ ...realAgent, Agent: {}, @@ -78,10 +64,13 @@ mock.module("@opencode-ai/core/util/log", () => ({ }), })) -import { generateCommitMessage } from "../../../src/kilocode/commit-message/generate" +import { CommitMessageRuntime, generateCommitMessage } from "../../../src/kilocode/commit-message/generate" + +const stream = spyOn(CommitMessageRuntime, "generate").mockImplementation(async () => mockStreamText) describe("commit-message.generate", () => { beforeEach(() => { + stream.mockImplementation(async () => mockStreamText) mockStreamText = "feat(src): add hello world logging" mockGitContext = { ...defaultGitContext } captured = { path: "" } diff --git a/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts b/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts index ee5f06c04d..35b25c1dd0 100644 --- a/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts +++ b/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts @@ -125,7 +125,6 @@ function llm() { const stream = typeof item === "function" ? item(input) : item return stream.pipe(Stream.mapEffect((event) => Effect.succeed(event))) }, - raw: () => Effect.die("raw not implemented in test LLM"), }), ), } diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 4f140684ff..d65dd8fd9b 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -11,7 +11,6 @@ import { WithInstance } from "../../src/project/with-instance" import { Provider } from "../../src/provider/provider" import { Question } from "../../src/question" import { Session } from "../../src/session/session" -import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { AppRuntime } from "../../src/effect/app-runtime" import { SessionStatus } from "../../src/session/status" @@ -235,17 +234,15 @@ function mockHandoverDeps(text: string, opts?: { agent?: Agent.Info | null }) { (opts?.agent === null ? undefined : (opts?.agent ?? fakeAgent)) as any, ) const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) - const llmSpy = spyOn(LLM, "stream").mockResolvedValue({ - text: Promise.resolve(text), - } as any) + const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue(text) return { agentSpy, modelSpy, - llmSpy, + handoverSpy, [Symbol.dispose]() { agentSpy.mockRestore() modelSpy.mockRestore() - llmSpy.mockRestore() + handoverSpy.mockRestore() }, } } @@ -568,16 +565,14 @@ describe("plan follow-up", () => { return fakeModel }, ) - const llmSpy = spyOn(LLM, "stream").mockResolvedValue({ - text: Promise.resolve( - "## Discoveries\n\nFound REST endpoints in src/api.ts\n\n## Relevant Files\n\n- src/api.ts: REST endpoints\n- src/db.ts: Database layer", - ), - } as any) + const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue( + "## Discoveries\n\nFound REST endpoints in src/api.ts\n\n## Relevant Files\n\n- src/api.ts: REST endpoints\n- src/db.ts: Database layer", + ) using _mocks = { - llmSpy, + handoverSpy, [Symbol.dispose]() { modelSpy.mockRestore() - llmSpy.mockRestore() + handoverSpy.mockRestore() }, } using _loop = { @@ -626,7 +621,7 @@ describe("plan follow-up", () => { expect(added).toHaveLength(1) expect(created).toHaveLength(1) expect(loop).toHaveBeenCalledTimes(1) - expect(_mocks.llmSpy).toHaveBeenCalledTimes(1) + expect(_mocks.handoverSpy).toHaveBeenCalledTimes(1) const newSessionID = created[0] const next = added[0] @@ -665,9 +660,7 @@ describe("plan follow-up", () => { await using other = await tmpdir({ git: true }) const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async () => undefined as any) const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) - const llmSpy = spyOn(LLM, "stream").mockResolvedValue({ - text: Promise.resolve(""), - } as any) + const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue("") const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({ info: { id: MessageID.make("msg_test"), @@ -695,7 +688,7 @@ describe("plan follow-up", () => { [Symbol.dispose]() { get.mockRestore() modelSpy.mockRestore() - llmSpy.mockRestore() + handoverSpy.mockRestore() loop.mockRestore() }, } @@ -984,12 +977,12 @@ describe("plan follow-up", () => { const deferred = Promise.withResolvers() const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any) const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) - const llmSpy = spyOn(LLM, "stream").mockResolvedValue({ - text: deferred.promise.then((t) => { + const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() => + deferred.promise.then((text) => { handoverResolvedAt = performance.now() - return t + return text }), - } as any) + ) const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({ info: { id: MessageID.make("msg_test"), @@ -1017,7 +1010,7 @@ describe("plan follow-up", () => { [Symbol.dispose]() { agentSpy.mockRestore() modelSpy.mockRestore() - llmSpy.mockRestore() + handoverSpy.mockRestore() loop.mockRestore() unsub() }, @@ -1071,9 +1064,7 @@ describe("plan follow-up", () => { const deferred = Promise.withResolvers() const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any) const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) - const llmSpy = spyOn(LLM, "stream").mockResolvedValue({ - text: deferred.promise, - } as any) + const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() => deferred.promise) const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({ info: { id: MessageID.make("msg_test"), @@ -1095,7 +1086,7 @@ describe("plan follow-up", () => { [Symbol.dispose]() { agentSpy.mockRestore() modelSpy.mockRestore() - llmSpy.mockRestore() + handoverSpy.mockRestore() loop.mockRestore() unsub() }, @@ -1170,9 +1161,7 @@ describe("plan follow-up", () => { const deferred = Promise.withResolvers() const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any) const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) - const llmSpy = spyOn(LLM, "stream").mockResolvedValue({ - text: deferred.promise, - } as any) + const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() => deferred.promise) const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({ info: { id: MessageID.make("msg_test"), @@ -1194,7 +1183,7 @@ describe("plan follow-up", () => { [Symbol.dispose]() { agentSpy.mockRestore() modelSpy.mockRestore() - llmSpy.mockRestore() + handoverSpy.mockRestore() loop.mockRestore() created() status() @@ -1316,16 +1305,16 @@ describe("plan follow-up", () => { expect(result).toBe("- [x] Set up project\n- [~] Write code\n- [ ] Add tests\n- [-] Dropped task") }) - test("generateHandover - returns empty string on LLM.stream failure", () => + test("generateHandover - returns empty string on LLM stream failure", () => withInstance(async () => { const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent) const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) - const llmSpy = spyOn(LLM, "stream").mockRejectedValue(new Error("provider unavailable")) + const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockRejectedValue(new Error("provider unavailable")) using _ = { [Symbol.dispose]() { agentSpy.mockRestore() modelSpy.mockRestore() - llmSpy.mockRestore() + handoverSpy.mockRestore() }, } const seeded = await seed({ text: "1. Build\n2. Test" }) @@ -1333,22 +1322,16 @@ describe("plan follow-up", () => { expect(result).toBe("") })) - test("generateHandover - returns empty string on stream.text rejection", () => + test("generateHandover - returns empty string on text stream rejection", () => withInstance(async () => { const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent) const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) - const textPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("stream aborted")), 0) - }) - textPromise.catch(() => {}) - const llmSpy = spyOn(LLM, "stream").mockResolvedValue({ - text: textPromise, - } as any) + const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockRejectedValue(new Error("stream aborted")) using _ = { [Symbol.dispose]() { agentSpy.mockRestore() modelSpy.mockRestore() - llmSpy.mockRestore() + handoverSpy.mockRestore() }, } const seeded = await seed({ text: "1. Build\n2. Test" }) @@ -1363,7 +1346,7 @@ describe("plan follow-up", () => { const result = await generateHandover({ messages: seeded.messages, model }) expect(result).toBe("## Discoveries\n\nFallback works") expect(mocks.agentSpy).toHaveBeenCalledWith("compaction") - expect(mocks.llmSpy).toHaveBeenCalledTimes(1) + expect(mocks.handoverSpy).toHaveBeenCalledTimes(1) })) test("generateHandover - returns LLM output on success", () => @@ -1372,6 +1355,6 @@ describe("plan follow-up", () => { const seeded = await seed({ text: "1. Build\n2. Test" }) const result = await generateHandover({ messages: seeded.messages, model }) expect(result).toBe("## Discoveries\n\nKey finding here") - expect(mocks.llmSpy).toHaveBeenCalledTimes(1) + expect(mocks.handoverSpy).toHaveBeenCalledTimes(1) })) }) diff --git a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts index 61fcf5aae5..ecaf77edf5 100644 --- a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts @@ -117,7 +117,6 @@ function llm() { const stream = typeof item === "function" ? item(input) : item return stream.pipe(Stream.mapEffect((event) => Effect.succeed(event))) }, - raw: () => Effect.die("raw not implemented in test LLM"), }), ), } diff --git a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts index 86be4a9415..9f8ec2f660 100644 --- a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts +++ b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts @@ -84,7 +84,6 @@ const llm = Layer.unwrap( const item = queue.shift() ?? Stream.empty return item }, - raw: () => Effect.die("raw not implemented in TestLLM"), }), ), Layer.succeed(TestLLM, TestLLM.of({ reply })), diff --git a/packages/opencode/test/kilocode/session-processor-network-offline.test.ts b/packages/opencode/test/kilocode/session-processor-network-offline.test.ts index 7cae070859..e9deb7dfb0 100644 --- a/packages/opencode/test/kilocode/session-processor-network-offline.test.ts +++ b/packages/opencode/test/kilocode/session-processor-network-offline.test.ts @@ -83,7 +83,6 @@ const llm = Layer.unwrap( const item = queue.shift() ?? Stream.empty return item }, - raw: () => Effect.die("raw not implemented in TestLLM"), }), ), Layer.succeed(TestLLM, TestLLM.of({ push })), diff --git a/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts b/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts index da0ba67288..45866a37d2 100644 --- a/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts +++ b/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts @@ -96,7 +96,6 @@ const llm = Layer.unwrap( const item = queue.shift() ?? Stream.fail(new Error("unexpected extra llm call")) return item }, - raw: () => Effect.die("raw not implemented in TestLLM"), }), ), Layer.succeed(TestLLM, TestLLM.of({ push, calls: Effect.sync(() => calls) })), diff --git a/packages/opencode/test/kilocode/session/llm.test.ts b/packages/opencode/test/kilocode/session/llm.test.ts new file mode 100644 index 0000000000..d4b9e4e4e0 --- /dev/null +++ b/packages/opencode/test/kilocode/session/llm.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Stream } from "effect" +import { KiloLLM } from "@/kilocode/session/llm" +import type { LLM } from "@/session/llm" + +describe("kilocode.session.llm.text", () => { + test("joins text delta events", async () => { + const out = await Effect.runPromise( + KiloLLM.text( + Stream.make( + { type: "text-delta", id: "text", text: "hello ", delta: "hello " } as LLM.Event, + { type: "text-delta", id: "text", text: "world", delta: "world" } as LLM.Event, + ), + ), + ) + + expect(out).toBe("hello world") + }) + + test("fails on error events after partial text", async () => { + const err = new Error("provider unavailable") + const text = KiloLLM.text( + Stream.make( + { type: "text-delta", id: "text", text: "partial", delta: "partial" } as LLM.Event, + { type: "error", error: err } as LLM.Event, + ), + ) + + await expect(Effect.runPromise(text)).rejects.toThrow("provider unavailable") + }) + + test("fails on abort events", async () => { + const text = KiloLLM.text(Stream.make({ type: "abort" } as LLM.Event)) + + await expect(Effect.runPromise(text)).rejects.toMatchObject({ name: "AbortError" }) + }) +}) diff --git a/packages/opencode/test/kilocode/suggestion/tool.test.ts b/packages/opencode/test/kilocode/suggestion/tool.test.ts index 88cdf2769d..41c932de7a 100644 --- a/packages/opencode/test/kilocode/suggestion/tool.test.ts +++ b/packages/opencode/test/kilocode/suggestion/tool.test.ts @@ -22,7 +22,16 @@ const command = Layer.succeed( list: () => Effect.succeed(Object.values(cmds)), }), ) -const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer, command)) +const statuses: Array<[string, SessionStatus.Info]> = [] +const status = Layer.succeed( + SessionStatus.Service, + SessionStatus.Service.of({ + get: () => Effect.succeed({ type: "idle" }), + list: () => Effect.succeed(new Map()), + set: (sessionID, value) => Effect.sync(() => statuses.push([sessionID, value])), + }), +) +const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer, command, status)) const init = Effect.fn("SuggestToolTest.init")(function* () { const info = yield* SuggestTool @@ -54,18 +63,16 @@ const ctx = { describe("tool.suggest", () => { let show: ReturnType - let statusSet: ReturnType beforeEach(() => { show = spyOn(Suggestion, "show") - statusSet = spyOn(SessionStatus, "set").mockResolvedValue(undefined as any) names.length = 0 + statuses.length = 0 for (const name of Object.keys(cmds)) delete cmds[name] }) afterEach(() => { show.mockRestore() - statusSet.mockRestore() }) it.live("returns dismissal result when suggestion is dismissed", () => @@ -250,8 +257,8 @@ describe("tool.suggest", () => { // idle status call has been issued. yield* Effect.sleep("10 millis") - expect(statusSet).toHaveBeenCalledWith(ctx.sessionID, { type: "idle" }) - expect(statusSet).not.toHaveBeenCalledWith(ctx.sessionID, { type: "busy" }) + expect(statuses).toContainEqual([ctx.sessionID, { type: "idle" }]) + expect(statuses).not.toContainEqual([ctx.sessionID, { type: "busy" }]) resolveShow({ label: "Start", prompt: "do it" }) yield* Fiber.join(pending) @@ -275,10 +282,7 @@ describe("tool.suggest", () => { ctx as any, ) - const statuses = statusSet.mock.calls - .filter((call: unknown[]) => call[0] === ctx.sessionID) - .map((call: unknown[]) => (call[1] as { type: string }).type) - expect(statuses).toEqual(["idle", "busy"]) + expect(statuses.map(([, value]) => value.type)).toEqual(["idle", "busy"]) }), ) @@ -299,10 +303,7 @@ describe("tool.suggest", () => { ctx as any, ) - const statuses = statusSet.mock.calls - .filter((call: unknown[]) => call[0] === ctx.sessionID) - .map((call: unknown[]) => (call[1] as { type: string }).type) - expect(statuses).toEqual(["idle"]) + expect(statuses.map(([, value]) => value.type)).toEqual(["idle"]) }), ) }) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 3c30d7181b..c49e70af33 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -270,7 +270,6 @@ function llm() { const stream = typeof item === "function" ? item(input) : item return stream.pipe(Stream.mapEffect((event) => Effect.succeed(event))) }, - raw: () => Effect.die("raw not implemented in test LLM"), }), ), } diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 97a822393a..599382fbe6 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -26,6 +26,7 @@ import { Ripgrep } from "@/file/ripgrep" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" import { WithInstance } from "@/project/with-instance" +import { SessionStatus } from "@/session/status" // kilocode_change const node = CrossSpawnSpawner.defaultLayer const configLayer = TestConfig.layer({ @@ -52,6 +53,7 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(Truncate.defaultLayer), Layer.provide(Command.defaultLayer), // kilocode_change Layer.provide(Git.defaultLayer), // kilocode_change + Layer.provide(SessionStatus.defaultLayer), // kilocode_change ) const it = testEffect(Layer.mergeAll(registryLayer, node))