Effect Migration for Kilo callsites (follow-up 2) (#10616)

* refactor(opencode): remove raw LLM interface in favor of stream-based text helper

Introduce KiloLLM.text() to consume Effect streams and extract text
while propagating error/abort events. Replace all usages of the removed
LLM.raw() method and the legacy LLM.stream() async wrapper with
dedicated runtime helpers (CommitMessageRuntime.generate,
PlanFollowupRuntime.handover) that leverage the new stream-based
approach.

- Add KiloLLM.text() stream combinator in kilocode/session/llm.ts
- Remove Interface.raw and the exported LLM.stream() async function
- Refactor commit-message generation to use CommitMessageRuntime
- Refactor plan-followup handover to use PlanFollowupRuntime.handover
- Update all test LLM mocks to remove raw() stubs
- Add unit tests for KiloLLM.text() covering text joining, error
  propagation, and abort handling

* feat(skill): migrate Skill module to Effect service pattern and remove legacy promise wrappers

Remove the makeRuntime-based promise helpers (Skill.all, Skill.get,
Skill.dirs) from the skill module and convert the builtin-skills test
suite to use Effect generators with the testEffect harness.

- Delete legacy runPromise wrappers from packages/opencode/src/skill/index.ts
- Rewrite builtin-skills.test.ts to use testEffect and Effect.gen
- Replace WithInstance.provide/tmpdir with TestInstance yield pattern
- Use Skill.Service directly within Effect generators for all assertions

* refactor(session-status): drop makeRuntime promise helpers and wire Service through Effect context

Replace the standalone `SessionStatus.list`, `.get`, `.set` promise
wrappers with direct `SessionStatus.Service` usage via Effect generators
and `AppRuntime.runPromise` at Kilo callsites that remain imperative.

- Remove makeRuntime-based exports from session/status.ts
- Update kilo-sessions.ts and plan-followup.ts to use AppRuntime.runPromise
  with SessionStatus.Service
- Thread SessionStatus.Service as a dependency through SuggestTool and
  ToolRegistry layers
- Replace spy-based mocks in suggestion tool tests with an in-memory
  service stub for deterministic assertions
This commit is contained in:
Imanol Maiztegui
2026-05-27 15:42:09 +02:00
committed by GitHub
parent 33ff7cff47
commit 1a36cfbb10
21 changed files with 272 additions and 258 deletions
@@ -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<string, SessionStatus.Info> = Object.fromEntries(statusMap)
const ids = new Set(Object.keys(statuses))
for (const id of focused) ids.add(id)
@@ -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) }
+33 -26
View File
@@ -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<SessionID, AbortController>()
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 })
})
@@ -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<Event, unknown>) {
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).
@@ -61,57 +61,59 @@ export function resolvePrompt(prompt: string, commands: Command.Interface) {
})
}
export const SuggestTool = Tool.define<typeof Params, Meta, Command.Service, "suggest">(
export const SuggestTool = Tool.define<typeof Params, Meta, Command.Service | SessionStatus.Service, "suggest">(
"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 = {
+1 -11
View File
@@ -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<infer T> ? T : ne
export interface Interface {
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
readonly raw: (input: StreamRequest) => Effect.Effect<Result> // kilocode_change - raw streamText result for Kilo helpers
}
export class Service extends Context.Service<Service, Interface>()("@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<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
Object.keys(input.tools),
-9
View File
@@ -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"
-8
View File
@@ -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) {
+3
View File
@@ -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
@@ -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 },
)
@@ -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: "" }
@@ -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"),
}),
),
}
@@ -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<string>()
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<string>()
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<string>()
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<string>((_, 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)
}))
})
@@ -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"),
}),
),
}
@@ -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 })),
@@ -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 })),
@@ -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) })),
@@ -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" })
})
})
@@ -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<typeof spyOn>
let statusSet: ReturnType<typeof spyOn>
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"])
}),
)
})
@@ -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"),
}),
),
}
@@ -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))