Merge remote-tracking branch 'origin/investigate-worktree-variant-inheritance' into investigate-worktree-variant-inheritance

This commit is contained in:
marius-kilocode
2026-07-13 18:55:24 +02:00
1294 changed files with 106365 additions and 35211 deletions
+8 -7
View File
@@ -1,20 +1,21 @@
import { expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { sql } from "drizzle-orm"
import { AccountRepo } from "../../src/account/repo"
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard(
Effect.sync(() => {
const db = Database.Client()
db.run(/*sql*/ `DELETE FROM account_state`)
db.run(/*sql*/ `DELETE FROM account`)
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.run(sql`DELETE FROM account_state`)
yield* db.run(sql`DELETE FROM account`)
}),
)
).pipe(Layer.provide(Database.defaultLayer))
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate))
it.live("list returns empty when no accounts exist", () =>
Effect.gen(function* () {
@@ -1,5 +1,6 @@
import { expect } from "bun:test"
import { Duration, Effect, Layer, Option, Schema } from "effect"
import { sql } from "drizzle-orm"
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo"
@@ -15,18 +16,18 @@ import {
RefreshToken,
UserCode,
} from "../../src/account/schema"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard(
Effect.sync(() => {
const db = Database.Client()
db.run(/*sql*/ `DELETE FROM account_state`)
db.run(/*sql*/ `DELETE FROM account`)
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.run(sql`DELETE FROM account_state`)
yield* db.run(sql`DELETE FROM account`)
}),
)
).pipe(Layer.provide(Database.defaultLayer))
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate))
const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1))
const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10))
+9 -8
View File
@@ -1,7 +1,8 @@
import { describe, expect } from "bun:test"
import { Directory } from "@/acp/directory"
import { Command } from "@/command"
import { ModelID, ProviderID } from "@/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
import { it } from "../lib/effect"
@@ -13,8 +14,8 @@ const command = (name: string): Command.Info => ({
hints: [],
})
const model = (providerID: ProviderID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({
id: ModelID.make(id),
const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({
id: ModelV2.ID.make(id),
providerID,
api: {
id,
@@ -49,8 +50,8 @@ const model = (providerID: ProviderID, id: string, variants?: Directory.ModelVar
})
const snapshot = (directory: string) => {
const providerID = ProviderID.make(`provider-${directory}`)
const modelID = ModelID.make(`model-${directory}`)
const providerID = ProviderV2.ID.make(`provider-${directory}`)
const modelID = ModelV2.ID.make(`model-${directory}`)
const providers = {
[providerID]: {
id: providerID,
@@ -63,10 +64,10 @@ const snapshot = (directory: string) => {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
}),
[ModelID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
[ModelV2.ID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
},
},
} satisfies Record<ProviderID, Provider.Info>
} satisfies Record<ProviderV2.ID, Provider.Info>
return Directory.build({
directory,
@@ -148,7 +149,7 @@ describe("ACP directory snapshot", () => {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
})
expect(directory.variants(alpha, { ...model, modelID: ModelID.make("missing") })).toBeUndefined()
expect(directory.variants(alpha, { ...model, modelID: ModelV2.ID.make("missing") })).toBeUndefined()
}).pipe(Effect.provide(fakeLayer([]))),
)
+57 -3
View File
@@ -251,6 +251,11 @@ function completedTool(
callID: string,
output = "done",
attachments: Extract<ToolPart["state"], { status: "completed" }>["attachments"] = [],
options: {
readonly tool?: string
readonly input?: Record<string, unknown>
readonly metadata?: Record<string, unknown>
} = {},
) {
return {
id: `part_${callID}`,
@@ -258,13 +263,13 @@ function completedTool(
messageID: `msg_${callID}`,
type: "tool",
callID,
tool: "bash",
tool: options.tool ?? "bash",
state: {
status: "completed",
input: { cmd: "printf done" },
input: options.input ?? { cmd: "printf done" },
output,
title: "bash",
metadata: { exit: 0 },
metadata: options.metadata ?? { exit: 0 },
time: { start: Date.now() - 1, end: Date.now() },
...(attachments.length ? { attachments } : {}),
},
@@ -605,6 +610,55 @@ describe("acp event routing", () => {
})
})
it("emits clean read display content and preserves rawOutput", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_read", cwd: "/workspace" }))
const output = [
"<path>/workspace/file.ts</path>",
"<type>file</type>",
"<content>",
"1: import { value } from './value'",
"2: export { value }",
"",
"(End of file - total 2 lines)",
"</content>",
].join("\n")
const metadata = {
display: {
type: "file",
path: "/workspace/file.ts",
text: "import { value } from './value'\nexport { value }",
lineStart: 1,
lineEnd: 2,
totalLines: 2,
truncated: false,
},
}
await harness.subscription.handle(
toolUpdated(
completedTool("ses_read", "call_read", output, [], {
tool: "read",
input: { filePath: "/workspace/file.ts" },
metadata,
}),
),
)
expect(harness.updates.at(-1)?.update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "call_read",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: "import { value } from './value'\nexport { value }" },
},
],
rawOutput: { output, metadata },
})
})
it("emits error tool output", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_error", cwd: "/workspace" }))
@@ -165,6 +165,42 @@ describe("acp permissions", () => {
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
})
it("forwards external_directory metadata and locations to requestPermission", async () => {
const harness = createHarness()
await createSession(harness.session, "ses_a")
harness.subscription.handle(
permissionAsked("ses_a", "perm_external", {
permission: "external_directory",
metadata: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
tool: { messageID: "msg_1", callID: "call_1" },
}),
)
await pollUntil(() => harness.replies.length === 1, "external_directory permission was never replied")
expect(harness.requests[0]).toMatchObject({
sessionId: "ses_a",
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "external_directory",
rawInput: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
locations: [{ path: "/tmp/outside" }],
},
})
})
it("rejects non-selected outcomes", async () => {
const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } }))
await createSession(harness.session, "ses_a")
@@ -11,18 +11,18 @@ import type {
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import type { KiloClient } from "@kilocode/sdk/v2"
import { Effect, ManagedRuntime } from "effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Effect } from "effect"
import * as ACPService from "@/acp/service"
import * as ACPError from "@/acp/error"
import { ACPSession } from "@/acp/session"
import { UsageService } from "@/acp/usage"
import { ModelID, ProviderID } from "@/provider/schema"
import type { Provider } from "@/provider/provider"
const providerID = ProviderID.make("test")
const modelID = ModelID.make("test-model")
const configuredModelID = ModelID.make("configured-model")
const secondModelID = ModelID.make("second-model")
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("test-model")
const configuredModelID = ModelV2.ID.make("configured-model")
const secondModelID = ModelV2.ID.make("second-model")
const provider: Provider.Info = {
id: providerID,
@@ -142,7 +142,10 @@ const provider: Provider.Info = {
}
describe("ACP service sessions", () => {
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
const makeService = (
messages: readonly { info: unknown; parts: readonly unknown[] }[] = [],
options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> },
) => {
const updates: SessionNotification[] = []
const mcpAdds: string[] = []
const aborts: string[] = []
@@ -220,10 +223,12 @@ describe("ACP service sessions", () => {
summarizes.push(input)
return Promise.resolve({ data: true })
},
abort: (input: { sessionID: string }) => {
aborts.push(input.sessionID)
return Promise.resolve({ data: true })
},
abort:
options?.abort ??
((input: { sessionID: string }) => {
aborts.push(input.sessionID)
return Promise.resolve({ data: true })
}),
fork: (input: { sessionID: string }) => {
forks.push(input.sessionID)
return Promise.resolve({ data: { id: `fork_${input.sessionID}` } })
@@ -311,6 +316,46 @@ describe("ACP service sessions", () => {
expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
})
it("replays loaded session transcript chunks", async () => {
const { service, updates } = makeService([
{
info: { id: "msg_user", sessionID: "ses_loaded", role: "user" },
parts: [{ id: "part_user", sessionID: "ses_loaded", messageID: "msg_user", type: "text", text: "hello" }],
},
{
info: { id: "msg_assistant", sessionID: "ses_loaded", role: "assistant" },
parts: [
{
id: "part_assistant",
sessionID: "ses_loaded",
messageID: "msg_assistant",
type: "text",
text: "hi there",
},
],
},
])
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
expect(
updates
.map((item) => item.update)
.filter((item) => item.sessionUpdate === "user_message_chunk" || item.sessionUpdate === "agent_message_chunk"),
).toEqual([
{
sessionUpdate: "user_message_chunk",
messageId: "msg_user",
content: { type: "text", text: "hello" },
},
{
sessionUpdate: "agent_message_chunk",
messageId: "msg_assistant",
content: { type: "text", text: "hi there" },
},
])
})
it("lists sessions sorted by updated time with cursor support", async () => {
const { service } = makeService()
const first = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
@@ -381,34 +426,28 @@ describe("ACP service sessions", () => {
expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({})
})
it("does not fail close when backing abort fails", async () => {
const sessionService = ManagedRuntime.make(ACPSession.defaultLayer).runSync(
ACPSession.Service.use((service) => Effect.succeed(service)),
)
const { service } = makeService()
const sdk = {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
session: {
abort: () => Promise.reject(new Error("nope")),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as KiloClient
const closing = ACPService.make({ sdk, session: sessionService })
await Effect.runPromise(sessionService.create({ id: "ses_close", cwd: "/workspace" }))
it("cancel aborts the backing session and keeps the ACP session", async () => {
const { service, aborts } = makeService()
const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(await Effect.runPromise(closing.closeSession({ sessionId: "ses_close" }))).toEqual({})
await Effect.runPromise(service.cancel({ sessionId: created.sessionId }))
// The running turn was aborted via the core session API.
expect(aborts).toEqual([created.sessionId])
// Unlike closeSession, the ACP session is still present afterwards so
// the client can keep prompting.
const stillUsable = await Effect.runPromise(
service.setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" }),
)
expect(stillUsable).toBeDefined()
})
it("does not fail cancel or close when the backing abort fails", async () => {
const { service } = makeService([], { abort: () => Promise.reject(new Error("nope")) })
const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(service.cancel({ sessionId: created.sessionId }))
expect(await Effect.runPromise(service.closeSession({ sessionId: created.sessionId }))).toEqual({})
expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({})
})
+4 -3
View File
@@ -1,16 +1,17 @@
import { describe, expect } from "bun:test"
import type { McpServer } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import * as ACPError from "@/acp/error"
import * as ACPSession from "@/acp/session"
import { ModelID, ProviderID } from "@/provider/schema"
import { testEffect } from "../lib/effect"
const sessionTest = testEffect(ACPSession.defaultLayer)
const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({
providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID),
providerID: ProviderV2.ID.make(providerID),
modelID: ModelV2.ID.make(modelID),
})
const mcpServer: McpServer = {
+45 -4
View File
@@ -15,15 +15,15 @@ describe("acp tool conversion", () => {
expect(toToolKind("shell")).toBe("execute")
expect(toToolKind("webfetch")).toBe("fetch")
expect(toToolKind("edit")).toBe("edit")
expect(toToolKind("apply_patch")).toBe("edit")
expect(toToolKind("patch")).toBe("edit")
expect(toToolKind("write")).toBe("edit")
expect(toToolKind("grep")).toBe("search")
expect(toToolKind("glob")).toBe("search")
expect(toToolKind("repo_clone")).toBe("search")
expect(toToolKind("repo_overview")).toBe("search")
expect(toToolKind("context7_resolve_library_id")).toBe("search")
expect(toToolKind("context7_get_library_docs")).toBe("search")
expect(toToolKind("read")).toBe("read")
expect(toToolKind("task")).toBe("think")
expect(toToolKind("custom_tool")).toBe("other")
})
@@ -33,9 +33,10 @@ describe("acp tool conversion", () => {
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
expect(toLocations("repo_clone", { path: "/repo" })).toEqual([{ path: "/repo" }])
expect(toLocations("repo_overview", { path: "/repo" })).toEqual([{ path: "/repo" }])
expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
{ path: "/tmp/outside" },
])
expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
})
@@ -103,6 +104,46 @@ describe("acp tool conversion", () => {
])
})
test("uses clean read display text for completed content", () => {
const output = [
"<path>/tmp/file.ts</path>",
"<type>file</type>",
"<content>",
"7: first",
"8: second",
"",
"(End of file - total 8 lines)",
"</content>",
].join("\n")
const state = {
status: "completed" as const,
input: { filePath: "/tmp/file.ts" },
output,
metadata: {
display: {
type: "file",
path: "/tmp/file.ts",
text: "first\nsecond",
lineStart: 7,
lineEnd: 8,
totalLines: 8,
truncated: false,
},
},
}
expect(completedToolContent("read", state)).toEqual([
{
type: "content",
content: { type: "text", text: "first\nsecond" },
},
])
expect(completedToolRawOutput(state)).toEqual({
output,
metadata: state.metadata,
})
})
test("builds completed raw output with optional metadata and attachments", () => {
const attachments = [
{
+11 -10
View File
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { UsageService } from "@/acp/usage"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
import { it } from "../lib/effect"
@@ -41,7 +42,7 @@ const assistantWithoutProvider = (): UsageService.SessionMessage => ({
},
})
const model = (providerID: ProviderID, modelID: ModelID, context: number): Provider.Model => ({
const model = (providerID: ProviderV2.ID, modelID: ModelV2.ID, context: number): Provider.Model => ({
id: modelID,
providerID,
api: {
@@ -75,9 +76,9 @@ const model = (providerID: ProviderID, modelID: ModelID, context: number): Provi
release_date: "2026-01-01",
})
const providers = (context = 128_000): Record<ProviderID, Provider.Info> => {
const providerID = ProviderID.make("anthropic")
const modelID = ModelID.make("claude-sonnet")
const providers = (context = 128_000): Record<ProviderV2.ID, Provider.Info> => {
const providerID = ProviderV2.ID.make("anthropic")
const modelID = ModelV2.ID.make("claude-sonnet")
return {
[providerID]: {
id: providerID,
@@ -94,7 +95,7 @@ const providers = (context = 128_000): Record<ProviderID, Provider.Info> => {
const fakeLayer = (input: {
readonly messages?: Effect.Effect<readonly UsageService.SessionMessage[], unknown>
readonly providers?: (directory: string) => Effect.Effect<Record<ProviderID, Provider.Info>, unknown>
readonly providers?: (directory: string) => Effect.Effect<Record<ProviderV2.ID, Provider.Info>, unknown>
}) =>
UsageService.layer.pipe(
Layer.provide(
@@ -178,13 +179,13 @@ describe("acp usage", () => {
const usage = yield* UsageService.Service
const first = yield* usage.contextLimit({
directory: "/workspace",
providerID: ProviderID.make("anthropic"),
modelID: ModelID.make("claude-sonnet"),
providerID: ProviderV2.ID.make("anthropic"),
modelID: ModelV2.ID.make("claude-sonnet"),
})
const second = yield* usage.contextLimit({
directory: "/workspace",
providerID: ProviderID.make("anthropic"),
modelID: ModelID.make("claude-sonnet"),
providerID: ProviderV2.ID.make("anthropic"),
modelID: ModelV2.ID.make("claude-sonnet"),
})
expect(first).toBe(200_000)
+13 -11
View File
@@ -9,6 +9,7 @@ import { Config } from "../../src/config/config"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Global } from "@opencode-ai/core/global"
import { Permission } from "../../src/permission"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Plugin } from "../../src/plugin"
import { Provider } from "../../src/provider/provider"
import { Skill } from "../../src/skill"
@@ -27,10 +28,10 @@ const agentLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
)
const it = testEffect(agentLayer())
const scout = testEffect(agentLayer({ experimentalScout: true }))
const scout = testEffect(agentLayer({ experimentalScout: true })) // kilocode_change
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionV1.Action | undefined {
if (!agent) return undefined
return Permission.evaluate(permission, "*", agent.permission).action
}
@@ -57,7 +58,7 @@ it.instance("returns default native agents when no config", () =>
expect(names).toContain("plan")
expect(names).toContain("general")
expect(names).toContain("explore")
expect(names).not.toContain("scout")
expect(names).not.toContain("scout") // kilocode_change
expect(names).toContain("compaction")
expect(names).toContain("title")
expect(names).toContain("summary")
@@ -114,19 +115,20 @@ it.instance("explore agent asks for external directories and allows whitelisted
}),
)
// kilocode_change start - Scout is opt-in and owns repository research permissions
scout.instance("scout agent allows repo cloning and repo cache reads", () =>
Effect.gen(function* () {
const scout = yield* load((svc) => svc.get("scout"))
expect(scout).toBeDefined()
expect(scout?.mode).toBe("subagent")
expect(evalPerm(scout, "repo_clone")).toBe("allow")
expect(evalPerm(scout, "repo_overview")).toBe("allow")
expect(evalPerm(scout, "edit")).toBe("deny")
const agent = yield* load((svc) => svc.get("scout"))
expect(agent).toBeDefined()
expect(agent?.mode).toBe("subagent")
expect(evalPerm(agent, "repo_clone")).toBe("allow")
expect(evalPerm(agent, "repo_overview")).toBe("allow")
expect(evalPerm(agent, "edit")).toBe("deny")
expect(
Permission.evaluate(
"external_directory",
path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"),
scout!.permission,
agent!.permission,
).action,
).toBe("allow")
}),
@@ -138,7 +140,6 @@ scout.instance(
Effect.gen(function* () {
const agents = yield* load((svc) => svc.list())
const names = agents.map((agent) => agent.name)
expect(names).toContain("scout")
expect(names).toContain("effect")
expect(names).toContain("effectFull")
expect(names).toContain("localdocs")
@@ -160,6 +161,7 @@ scout.instance(
},
},
)
// kilocode_change end
it.instance("general agent denies todo tools", () =>
Effect.gen(function* () {
@@ -1,3 +1,4 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
/**
* Reproducer for opencode issue #26514:
*
@@ -60,7 +61,7 @@ it.instance("[#26514] subagent spawned from plan mode inherits read-only restric
// session's `permission` field is empty (Plan Mode lives on the agent
// ruleset, not the session). So we pass [] through as the parent
// session permission, exactly like the actual code path.
const parentSessionPermission: Permission.Ruleset = []
const parentSessionPermission: PermissionV1.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
@@ -88,7 +89,7 @@ it.instance("[#26514] explore subagent launched from plan mode also stays read-o
expect(planAgent).toBeDefined()
expect(explore).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const parentSessionPermission: PermissionV1.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
@@ -113,7 +114,7 @@ it.instance(
expect(planAgent).toBeDefined()
expect(my).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const parentSessionPermission: PermissionV1.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
@@ -1,11 +1,11 @@
import { expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import path from "path"
import { pathToFileURL } from "url"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Config } from "../../src/config/config"
import { Env } from "../../src/env"
import { Git } from "../../src/git" // kilocode_change
@@ -20,8 +20,8 @@ import { SkillTest } from "../fake/skill"
import { testEffect } from "../lib/effect"
import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants"
// `it.instance` skips InstanceBootstrap so FileWatcher / LSP / MCP don't spin
// up — those services hang during scope teardown on Windows and aren't needed
// `it.instance` skips InstanceBootstrap so LSP / MCP don't spin up — those
// services hang during scope teardown on Windows and aren't needed
// to verify plugin → config hook → Agent.list.
const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href
@@ -29,7 +29,7 @@ const provider = ProviderTest.fake()
const configLayer = Config.layer.pipe(
Layer.provide(Git.defaultLayer), // kilocode_change
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(AuthTest.empty),
Layer.provide(AccountTest.empty),
@@ -37,7 +37,7 @@ const configLayer = Config.layer.pipe(
Layer.provide(FetchHttpClient.layer),
)
const pluginLayer = Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
)
const dependencies = Layer.mergeAll(configLayer, pluginLayer).pipe(Layer.provideMerge(configLayer))
+58 -67
View File
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Auth } from "../../src/auth"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const node = CrossSpawnSpawner.defaultLayer
@@ -10,77 +9,69 @@ const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node))
describe("Auth", () => {
it.live("set normalizes trailing slashes in keys", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
const data = yield* auth.all()
expect(data["https://example.com"]).toBeDefined()
expect(data["https://example.com/"]).toBeUndefined()
}),
),
it.instance("set normalizes trailing slashes in keys", () =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
const data = yield* auth.all()
expect(data["https://example.com"]).toBeDefined()
expect(data["https://example.com/"]).toBeUndefined()
}),
)
it.live("set cleans up pre-existing trailing-slash entry", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "old",
})
yield* auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "new",
})
const data = yield* auth.all()
const keys = Object.keys(data).filter((key) => key.includes("example.com"))
expect(keys).toEqual(["https://example.com"])
const entry = data["https://example.com"]!
expect(entry.type).toBe("wellknown")
if (entry.type === "wellknown") expect(entry.token).toBe("new")
}),
),
it.instance("set cleans up pre-existing trailing-slash entry", () =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "old",
})
yield* auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "new",
})
const data = yield* auth.all()
const keys = Object.keys(data).filter((key) => key.includes("example.com"))
expect(keys).toEqual(["https://example.com"])
const entry = data["https://example.com"]!
expect(entry.type).toBe("wellknown")
if (entry.type === "wellknown") expect(entry.token).toBe("new")
}),
)
it.live("remove deletes both trailing-slash and normalized keys", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
yield* auth.remove("https://example.com/")
const data = yield* auth.all()
expect(data["https://example.com"]).toBeUndefined()
expect(data["https://example.com/"]).toBeUndefined()
}),
),
it.instance("remove deletes both trailing-slash and normalized keys", () =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
yield* auth.remove("https://example.com/")
const data = yield* auth.all()
expect(data["https://example.com"]).toBeUndefined()
expect(data["https://example.com/"]).toBeUndefined()
}),
)
it.live("set and remove are no-ops on keys without trailing slashes", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("anthropic", {
type: "api",
key: "sk-test",
})
const data = yield* auth.all()
expect(data["anthropic"]).toBeDefined()
yield* auth.remove("anthropic")
const after = yield* auth.all()
expect(after["anthropic"]).toBeUndefined()
}),
),
it.instance("set and remove are no-ops on keys without trailing slashes", () =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("anthropic", {
type: "api",
key: "sk-test",
})
const data = yield* auth.all()
expect(data["anthropic"]).toBeDefined()
yield* auth.remove("anthropic")
const after = yield* auth.all()
expect(after["anthropic"]).toBeUndefined()
}),
)
})
@@ -78,6 +78,63 @@ describe("background.job", () => {
}),
)
it.instance("waits for extensions before completing a running job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const first = yield* Deferred.make<void>()
const second = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Deferred.await(first).pipe(Effect.as("first")),
})
expect(yield* jobs.extend({ id: job.id, run: Deferred.await(second).pipe(Effect.as("second")) })).toBe(true)
yield* Deferred.succeed(first, undefined)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(second, undefined)
const done = yield* jobs.wait({ id: job.id })
expect(done.info?.status).toBe("completed")
expect(done.info?.output).toBe("second")
}),
)
it.instance("runs extensions after earlier work completes", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const first = yield* Deferred.make<void>()
const order: string[] = []
const job = yield* jobs.start({
type: "test",
run: Effect.sync(() => order.push("start")).pipe(Effect.andThen(Deferred.await(first)), Effect.as("first")),
})
expect(
yield* jobs.extend({
id: job.id,
run: Effect.sync(() => order.push("extend")).pipe(Effect.as("second")),
}),
).toBe(true)
yield* Effect.yieldNow
expect(order).toEqual(["start"])
yield* Deferred.succeed(first, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("second")
expect(order).toEqual(["start", "extend"])
}),
)
it.instance("rejects extensions after a job completes", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({ type: "test", run: Effect.succeed("done") })
yield* jobs.wait({ id: job.id })
expect(yield* jobs.extend({ id: job.id, run: Effect.succeed("late") })).toBe(false)
expect((yield* jobs.get(job.id))?.output).toBe("done")
}),
)
it.instance("records failed jobs", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
@@ -93,6 +150,37 @@ describe("background.job", () => {
}),
)
it.instance("ignores stale settlements after restarting a failed job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const fail = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const id = "job_test"
yield* jobs.start({
id,
type: "test",
run: Deferred.await(fail).pipe(Effect.andThen(Effect.fail(new Error("boom")))),
})
yield* jobs.extend({
id,
run: Effect.never.pipe(
Effect.ensuring(Deferred.succeed(interrupted, undefined).pipe(Effect.andThen(Deferred.await(release)))),
),
})
yield* Deferred.succeed(fail, undefined)
expect((yield* jobs.wait({ id })).info?.status).toBe("error")
yield* Deferred.await(interrupted)
yield* jobs.start({ id, type: "test", run: Effect.never })
yield* Deferred.succeed(release, undefined)
yield* Effect.yieldNow
expect((yield* jobs.get(id))?.status).toBe("running")
yield* jobs.cancel(id)
}),
)
it.instance("can cancel running jobs", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
@@ -101,6 +189,10 @@ describe("background.job", () => {
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
yield* jobs.extend({
id: job.id,
run: Effect.never,
})
const cancelled = yield* jobs.cancel(job.id)
@@ -110,6 +202,30 @@ describe("background.job", () => {
}),
)
it.instance("promotes running jobs without interrupting them", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const latch = yield* Deferred.make<void>()
const promoted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
metadata: { parentSessionId: "parent" },
onPromote: Deferred.succeed(promoted, undefined).pipe(Effect.asVoid),
run: Deferred.await(latch).pipe(Effect.as("done")),
})
const info = yield* jobs.promote(job.id)
expect(info?.status).toBe("running")
expect(info?.metadata?.background).toBe(true)
yield* Deferred.await(promoted)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(latch, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("done")
}),
)
it.instance("returns immutable snapshots", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
@@ -1,288 +0,0 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Latch, Layer, Schema, Stream } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const TestEvent = {
Ping: BusEvent.define("test.effect.ping", Schema.Struct({ value: Schema.Number })),
Pong: BusEvent.define("test.effect.pong", Schema.Struct({ message: Schema.String })),
Warmup: BusEvent.define("test.effect.warmup", Schema.Struct({})),
}
const node = CrossSpawnSpawner.defaultLayer
const live = Layer.mergeAll(Bus.layer, node)
const it = testEffect(live)
// Publishes warmup events until the latch opens, proving the forked subscriber
// fiber has actually wired up its PubSub subscription.
const awaitSubscriberReady = Effect.fn("test.awaitSubscriberReady")(function* (
ready: Latch.Latch,
warmup: Effect.Effect<void>,
) {
const pump = yield* Effect.forkScoped(
Effect.gen(function* () {
while (true) {
yield* warmup
yield* Effect.sleep("5 millis")
}
}),
)
yield* ready.await.pipe(Effect.timeout("2 seconds"))
yield* Fiber.interrupt(pump)
})
describe("Bus (Effect-native)", () => {
it.instance("publish + subscribe stream delivers events", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: number[] = []
const done = yield* Deferred.make<void>()
const ready = yield* Latch.make()
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
yield* ready.open
return
}
received.push(evt.properties.value)
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Ping, { value: -1 }))
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* bus.publish(TestEvent.Ping, { value: 2 })
yield* Deferred.await(done)
expect(received).toEqual([1, 2])
}),
)
it.instance("subscribe filters by event type", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const pings: number[] = []
const done = yield* Deferred.make<void>()
const ready = yield* Latch.make()
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
yield* ready.open
return
}
pings.push(evt.properties.value)
Deferred.doneUnsafe(done, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Ping, { value: -1 }))
yield* bus.publish(TestEvent.Pong, { message: "ignored" })
yield* bus.publish(TestEvent.Ping, { value: 42 })
yield* Deferred.await(done)
expect(pings).toEqual([42])
}),
)
it.instance("subscribeAll receives all types", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const types: string[] = []
const done = yield* Deferred.make<void>()
const ready = yield* Latch.make()
yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) =>
Effect.gen(function* () {
if (evt.type === TestEvent.Warmup.type) {
yield* ready.open
return
}
types.push(evt.type)
if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Warmup, {}))
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* bus.publish(TestEvent.Pong, { message: "hi" })
yield* Deferred.await(done)
expect(types).toContain("test.effect.ping")
expect(types).toContain("test.effect.pong")
}),
)
it.instance("multiple subscribers each receive the event", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const a: number[] = []
const b: number[] = []
const doneA = yield* Deferred.make<void>()
const doneB = yield* Deferred.make<void>()
const readyA = yield* Latch.make()
const readyB = yield* Latch.make()
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
yield* readyA.open
return
}
a.push(evt.properties.value)
Deferred.doneUnsafe(doneA, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
yield* readyB.open
return
}
b.push(evt.properties.value)
Deferred.doneUnsafe(doneB, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* awaitSubscriberReady(readyA, bus.publish(TestEvent.Ping, { value: -1 }))
yield* awaitSubscriberReady(readyB, bus.publish(TestEvent.Ping, { value: -1 }))
yield* bus.publish(TestEvent.Ping, { value: 99 })
yield* Deferred.await(doneA)
yield* Deferred.await(doneB)
expect(a).toEqual([99])
expect(b).toEqual([99])
}),
)
// RACE 1: eager subscription means publishing immediately after yield*
// bus.subscribe is delivered. Regression for the old lazy `Stream.unwrap`
// shape where PubSub.subscribe ran on first pull and missed any publish
// in the hand-off window.
it.instance("eager subscribe: publish after yield* is delivered without consumer-activation race", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const stream = yield* bus.subscribe(TestEvent.Ping)
// Hand-off window: subscription is alive (we yielded). Publish goes
// straight into the subscription queue, even with no consumer running.
yield* bus.publish(TestEvent.Ping, { value: 99 })
const collected = yield* stream.pipe(
Stream.take(1),
Stream.runCollect,
Effect.timeout("400 millis"),
Effect.option,
)
expect(collected._tag).toBe("Some")
if (collected._tag === "Some") {
const arr = Array.from(collected.value)
expect(arr[0].properties.value).toBe(99)
}
}),
)
// RACE 2: same property for subscribeAll.
it.instance("eager subscribeAll: publish after yield* is delivered", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const stream = yield* bus.subscribeAll()
yield* bus.publish(TestEvent.Ping, { value: 42 })
const collected = yield* stream.pipe(
Stream.take(1),
Stream.runCollect,
Effect.timeout("400 millis"),
Effect.option,
)
expect(collected._tag).toBe("Some")
if (collected._tag === "Some") {
const arr = Array.from(collected.value)
expect(arr[0].type).toBe(TestEvent.Ping.type)
}
}),
)
// RACE 3: the /event-handler shape exactly. With eager subscription, the
// bus subscription is alive before Stream.concat ever starts. Publishes
// during the prefix consumption window are queued and delivered.
it.instance("eager subscribe: Stream.concat(initial, subscribe) delivers publish during prefix", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const sawInitial = yield* Deferred.make<void>()
const sawPublish = yield* Deferred.make<number>()
type Frame = { marker?: "initial"; value?: number }
const subscriptionStream = yield* bus.subscribe(TestEvent.Ping)
const handlerStream: Stream.Stream<Frame> = Stream.make({ marker: "initial" } as Frame).pipe(
Stream.concat(subscriptionStream.pipe(Stream.map((evt): Frame => ({ value: evt.properties.value })))),
)
yield* Stream.runForEach(handlerStream, (frame) =>
Effect.gen(function* () {
if (frame.marker === "initial") {
Deferred.doneUnsafe(sawInitial, Effect.void)
return
}
if (frame.value !== undefined) Deferred.doneUnsafe(sawPublish, Effect.succeed(frame.value))
}),
).pipe(Effect.forkScoped)
yield* Deferred.await(sawInitial).pipe(Effect.timeout("1 second"))
yield* bus.publish(TestEvent.Ping, { value: 7 })
const got = yield* Deferred.await(sawPublish).pipe(Effect.timeout("1 second"), Effect.option)
expect(got._tag).toBe("Some")
if (got._tag === "Some") expect(got.value).toBe(7)
}),
)
it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const types: string[] = []
const seen = yield* Deferred.make<void>()
const disposed = yield* Deferred.make<void>()
const ready = yield* Latch.make()
// Set up subscriber inside the instance
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) =>
Effect.gen(function* () {
if (evt.type === TestEvent.Warmup.type) {
yield* ready.open
return
}
types.push(evt.type)
if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void)
if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Warmup, {}))
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* Deferred.await(seen)
}).pipe(provideInstance(dir))
// Dispose from OUTSIDE the instance scope
yield* Effect.promise(disposeAllInstances)
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
expect(types).toContain("test.effect.ping")
expect(types).toContain(Bus.InstanceDisposed.type)
}),
)
})
@@ -1,88 +0,0 @@
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { afterEach, describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Schema } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number }))
const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer))
describe("Bus integration: acquireRelease subscriber pattern", () => {
afterEach(() => disposeAllInstances())
it.instance("subscriber via callback facade receives events and cleans up on unsub", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: number[] = []
const receivedTwo = yield* Deferred.make<void>()
const unsub = yield* bus.subscribeCallback(TestEvent, (evt) => {
received.push(evt.properties.value)
if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void)
})
yield* bus.publish(TestEvent, { value: 1 })
yield* bus.publish(TestEvent, { value: 2 })
yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds"))
expect(received).toEqual([1, 2])
yield* Effect.sync(unsub)
yield* bus.publish(TestEvent, { value: 3 })
yield* Effect.sleep("10 millis")
expect(received).toEqual([1, 2])
}),
)
it.instance("subscribeAll receives events from multiple types", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: Array<{ type: string; value?: number }> = []
const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number }))
const receivedTwo = yield* Deferred.make<void>()
yield* bus.subscribeAllCallback((evt) => {
received.push({ type: evt.type, value: evt.properties.value })
if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void)
})
yield* bus.publish(TestEvent, { value: 10 })
yield* bus.publish(OtherEvent, { value: 20 })
yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds"))
expect(received).toEqual([
{ type: "test.integration", value: 10 },
{ type: "test.other", value: 20 },
])
}),
)
it.live("subscriber cleanup on instance disposal interrupts the stream", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const received: number[] = []
const seen = yield* Deferred.make<void>()
const disposed = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.subscribeAllCallback((evt) => {
if (evt.type === Bus.InstanceDisposed.type) {
Deferred.doneUnsafe(disposed, Effect.void)
return
}
received.push(evt.properties.value)
Deferred.doneUnsafe(seen, Effect.void)
})
yield* bus.publish(TestEvent, { value: 1 })
yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds"))
}).pipe(provideInstance(dir))
yield* Effect.promise(() => disposeAllInstances())
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
expect(received).toEqual([1])
}),
)
})
-240
View File
@@ -1,240 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Deferred, Effect, Layer, Schema } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const TestEvent = {
Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })),
Pong: BusEvent.define("test.pong", Schema.Struct({ message: Schema.String })),
}
const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer))
describe("Bus", () => {
afterEach(() => disposeAllInstances())
describe("publish + subscribe", () => {
it.instance("subscriber is live immediately after subscribe returns", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: number[] = []
const done = yield* Deferred.make<void>()
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
received.push(evt.properties.value)
Deferred.doneUnsafe(done, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 42 })
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
expect(received).toEqual([42])
}),
)
it.instance("subscriber receives matching events", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: number[] = []
const done = yield* Deferred.make<void>()
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
received.push(evt.properties.value)
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 42 })
yield* bus.publish(TestEvent.Ping, { value: 99 })
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
expect(received).toEqual([42, 99])
}),
)
it.instance("subscriber does not receive events of other types", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const pings: number[] = []
const done = yield* Deferred.make<void>()
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
pings.push(evt.properties.value)
Deferred.doneUnsafe(done, Effect.void)
})
yield* bus.publish(TestEvent.Pong, { message: "hello" })
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
expect(pings).toEqual([1])
}),
)
it.instance("publish with no subscribers does not throw", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.publish(TestEvent.Ping, { value: 1 })
}),
)
})
describe("unsubscribe", () => {
it.instance("unsubscribe stops delivery", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: number[] = []
const first = yield* Deferred.make<void>()
const unsub = yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
received.push(evt.properties.value)
if (evt.properties.value === 1) Deferred.doneUnsafe(first, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* Deferred.await(first).pipe(Effect.timeout("2 seconds"))
yield* Effect.sync(unsub)
yield* bus.publish(TestEvent.Ping, { value: 2 })
yield* Effect.sleep("10 millis")
expect(received).toEqual([1])
}),
)
})
describe("subscribeAll", () => {
it.instance("subscribeAll is live immediately after subscribe returns", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: string[] = []
const done = yield* Deferred.make<void>()
yield* bus.subscribeAllCallback((evt) => {
received.push(evt.type)
Deferred.doneUnsafe(done, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
expect(received).toEqual(["test.ping"])
}),
)
it.instance("receives all event types", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: string[] = []
const done = yield* Deferred.make<void>()
yield* bus.subscribeAllCallback((evt) => {
received.push(evt.type)
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* bus.publish(TestEvent.Pong, { message: "hi" })
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
expect(received).toContain("test.ping")
expect(received).toContain("test.pong")
}),
)
})
describe("multiple subscribers", () => {
it.instance("all subscribers for same event type are called", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const a: number[] = []
const b: number[] = []
const doneA = yield* Deferred.make<void>()
const doneB = yield* Deferred.make<void>()
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
a.push(evt.properties.value)
Deferred.doneUnsafe(doneA, Effect.void)
})
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
b.push(evt.properties.value)
Deferred.doneUnsafe(doneB, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 7 })
yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds"))
yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds"))
expect(a).toEqual([7])
expect(b).toEqual([7])
}),
)
})
describe("instance isolation", () => {
it.live("events in one directory do not reach subscribers in another", () =>
Effect.gen(function* () {
const tmpA = yield* tmpdirScoped()
const tmpB = yield* tmpdirScoped()
const receivedA: number[] = []
const receivedB: number[] = []
const doneA = yield* Deferred.make<void>()
const doneB = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
receivedA.push(evt.properties.value)
Deferred.doneUnsafe(doneA, Effect.void)
})
}).pipe(provideInstance(tmpA))
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
receivedB.push(evt.properties.value)
Deferred.doneUnsafe(doneB, Effect.void)
})
}).pipe(provideInstance(tmpB))
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.publish(TestEvent.Ping, { value: 1 })
}).pipe(provideInstance(tmpA))
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.publish(TestEvent.Ping, { value: 2 })
}).pipe(provideInstance(tmpB))
yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds"))
yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds"))
expect(receivedA).toEqual([1])
expect(receivedB).toEqual([2])
}),
)
})
describe("instance disposal", () => {
it.live("InstanceDisposed is delivered to wildcard subscribers before stream ends", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const received: string[] = []
const seen = yield* Deferred.make<void>()
const disposed = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.subscribeAllCallback((evt) => {
received.push(evt.type)
if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void)
if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds"))
}).pipe(provideInstance(tmp))
yield* Effect.promise(disposeAllInstances)
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
expect(received).toContain("test.ping")
expect(received).toContain(Bus.InstanceDisposed.type)
}),
)
})
})
@@ -5,7 +5,7 @@
*/
import { describe, expect, test } from "bun:test"
import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures"
import { ConfigError } from "@/config/error"
import { ConfigErrorV1 } from "@opencode-ai/core/v1/config/error"
describe("aggregateFailures", () => {
test("returns null when every result is fulfilled", () => {
@@ -43,7 +43,7 @@ describe("aggregateFailures", () => {
})
test("formats structured config errors hidden inside SDK error causes", () => {
const configError = new ConfigError.InvalidError({
const configError = new ConfigErrorV1.InvalidError({
path: "/tmp/opencode.json",
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
})
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"
import { assign, strip } from "../../../../src/cli/cmd/tui/component/prompt/part"
import { assign, expandTrackedPastedText, strip } from "../../../../src/cli/cmd/tui/component/prompt/part"
describe("prompt part", () => {
test("strip removes persisted ids from reused file parts", () => {
@@ -44,4 +44,34 @@ describe("prompt part", () => {
url: "data:image/png;base64,abc",
})
})
test("expandTrackedPastedText preserves wide characters around pasted text", () => {
const marker = "[Pasted ~3 lines]"
const prefix = "你好你好\n"
expect(
expandTrackedPastedText(prefix + marker + "\n阿斯顿法国红酒看来", [
{
start: Bun.stringWidth("你好你好") + 1,
end: Bun.stringWidth("你好你好") + 1 + Bun.stringWidth(marker),
text: "public:\n\tvoid ExecuteTask();\nprivate:",
},
]),
).toBe("你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来")
})
test("expandTrackedPastedText only expands the tracked placeholder occurrence", () => {
const marker = "[Pasted ~3 lines]"
const prefix = `keep ${marker} then `
expect(
expandTrackedPastedText(prefix + marker + " tail", [
{
start: Bun.stringWidth(prefix),
end: Bun.stringWidth(prefix + marker),
text: "alpha\nbeta\ngamma",
},
]),
).toBe(`keep ${marker} then alpha\nbeta\ngamma tail`)
})
})
@@ -66,6 +66,7 @@ export async function mount(override?: FetchHandler) {
))
await ready
await project.sync() // kilocode_change - event routing requires the resolved project
await wait(() => sync.status === "complete")
return { app, emit: events.emit, kv, project, sync, session: calls.session }
}
@@ -0,0 +1,299 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { Global } from "@opencode-ai/core/global"
import type { GlobalEvent } from "@kilocode/sdk/v2"
import { tmpdir } from "../../../fixture/fixture"
import { json, mount, wait } from "./sync-fixture"
const sessionID = "ses_hydration_race"
const messageID = "msg_hydration_race"
const partID = "prt_hydration_race"
let seq = 0
const session = {
id: sessionID,
title: "race",
time: { created: 0, updated: 0 },
version: "1.15.13",
directory: "/tmp/opencode/packages/opencode",
}
const assistant = {
id: messageID,
sessionID,
role: "assistant" as const,
agent: "build",
modelID: "model",
providerID: "test",
mode: "build",
parentID: "msg_user",
path: { cwd: session.directory, root: session.directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 2 },
}
function global(payload: GlobalEvent["payload"]): GlobalEvent {
if (
payload.type === "message.updated" ||
payload.type === "message.part.updated" ||
payload.type === "message.removed"
) {
return {
directory: "/tmp/other",
project: "proj_test",
payload: {
type: "sync",
syncEvent: {
id: payload.id,
type: payload.type + ".1",
seq: ++seq,
aggregateID: payload.properties.sessionID,
data: payload.properties,
},
},
} as GlobalEvent
}
return { directory: "/tmp/other", project: "proj_test", payload }
}
test("stale session hydration does not overwrite live message parts", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
})
try {
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
emit(
global({
id: "evt_part",
type: "message.part.updated",
properties: {
sessionID,
time: 2,
part: { id: partID, sessionID, messageID, type: "text", text: "visible live content" },
},
}),
)
await wait(() => sync.data.part[messageID]?.[0]?.type === "text")
resolveMessages(
json([
{
info: assistant,
parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }],
},
]),
)
await hydrate
expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible live content" })
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})
test("orphan live deltas do not suppress hydrated parts", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
})
try {
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
emit(
global({
id: "evt_delta",
type: "message.part.delta",
properties: { sessionID, messageID, partID, field: "text", delta: "ignored until part exists" },
}),
)
resolveMessages(
json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "hydrated" }] }]),
)
await hydrate
expect(sync.data.part[messageID][0]).toMatchObject({ text: "hydrated" })
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})
test("hydration does not clear text streamed before it starts", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
})
try {
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
emit(
global({
id: "evt_part",
type: "message.part.updated",
properties: {
sessionID,
time: 1,
part: { id: partID, sessionID, messageID, type: "text", text: "" },
},
}),
)
emit(
global({
id: "evt_delta",
type: "message.part.delta",
properties: { sessionID, messageID, partID, field: "text", delta: "visible streamed content" },
}),
)
await wait(() => sync.data.part[messageID]?.[0]?.type === "text" && sync.data.part[messageID][0].text !== "")
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
resolveMessages(json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }] }]))
await hydrate
expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible streamed content" })
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})
test("live messages merged during hydration retain the 100 message window", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
})
try {
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
const live = { ...assistant, id: "msg_z_live" }
emit(global({ id: "evt_live", type: "message.updated", properties: { sessionID, info: live } }))
await wait(() => sync.data.message[sessionID]?.some((message) => message.id === live.id) ?? false)
resolveMessages(
json(
Array.from({ length: 100 }, (_, index) => {
const id = `msg_${String(index).padStart(3, "0")}`
return {
info: { ...assistant, id },
parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text: id }],
}
}),
),
)
await hydrate
expect(sync.data.message[sessionID]).toHaveLength(100)
expect(sync.data.message[sessionID].at(-1)?.id).toBe(live.id)
expect(sync.data.message[sessionID].some((message) => message.id === "msg_000")).toBe(false)
expect(sync.data.part.msg_000).toBeUndefined()
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})
test("a message removed during hydration does not regain stale parts", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
})
try {
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
await wait(() => sync.data.message[sessionID]?.length === 1)
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
emit(global({ id: "evt_removed", type: "message.removed", properties: { sessionID, messageID } }))
await wait(() => sync.data.message[sessionID]?.length === 0)
resolveMessages(
json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "stale" }] }]),
)
await hydrate
expect(sync.data.message[sessionID]).toEqual([])
expect(sync.data.part[messageID]).toBeUndefined()
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})
@@ -1,5 +1,5 @@
import { afterEach, expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect } from "effect"
import { effectCmd } from "../../src/cli/effect-cmd"
import { EffectBridge } from "../../src/effect/bridge"
@@ -8,7 +8,7 @@ import { Instance } from "../../src/kilocode/instance"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(AppFileSystem.defaultLayer)
const it = testEffect(FSUtil.defaultLayer)
afterEach(async () => {
await disposeAllInstances()
@@ -1,10 +1,11 @@
import { test, expect, describe } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github"
import type { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
// Helper to create minimal valid parts
function createTextPart(text: string): MessageV2.Part {
function createTextPart(text: string): SessionV1.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("ses_test"),
@@ -14,7 +15,7 @@ function createTextPart(text: string): MessageV2.Part {
}
}
function createReasoningPart(text: string): MessageV2.Part {
function createReasoningPart(text: string): SessionV1.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("ses_test"),
@@ -25,7 +26,7 @@ function createReasoningPart(text: string): MessageV2.Part {
}
}
function createToolPart(tool: string, title: string, status: "completed" | "running" = "completed"): MessageV2.Part {
function createToolPart(tool: string, title: string, status: "completed" | "running" = "completed"): SessionV1.Part {
if (status === "completed") {
return {
id: PartID.ascending(),
@@ -59,7 +60,7 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
}
}
function createStepStartPart(): MessageV2.Part {
function createStepStartPart(): SessionV1.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("ses_test"),
@@ -68,7 +69,7 @@ function createStepStartPart(): MessageV2.Part {
}
}
function createStepFinishPart(): MessageV2.Part {
function createStepFinishPart(): SessionV1.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("ses_test"),
@@ -105,8 +105,8 @@ Options:
--variant model variant (provider-specific reasoning effort, e.g., high,
max, minimal) [string]
--thinking show thinking blocks [boolean]
--replay replay visible session history on interactive resume
[boolean] [default: false]
--replay replay interactive session history on resume and after resize
(use --no-replay to disable) [boolean] [default: true]
--replay-limit cap visible interactive replay to the newest N messages
[number]
-i, --interactive run in direct interactive split-footer mode
@@ -402,7 +402,6 @@ database tools
Commands:
kilo db [query] open an interactive sqlite3 shell or run a query [default]
kilo db path print the database path
kilo db migrate migrate JSON data to SQLite (merges with existing data)
Positionals:
query SQL query to execute [string]
@@ -1,48 +1,40 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { RGBA, type BoxRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import { createSignal } from "solid-js"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import type { QuestionRequest } from "@kilocode/sdk/v2"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
import {
RUN_COMMAND_PANEL_ROWS,
RUN_SUBAGENT_PANEL_ROWS,
RunCommandMenuBody,
RunModelSelectBody,
RunQueuedPromptSelectBody,
RunSubagentSelectBody,
RunVariantSelectBody,
} from "@/cli/cmd/run/footer.command"
import { RunFooterView } from "@/cli/cmd/run/footer.view"
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme"
import type {
FooterKeybinds,
FooterState,
FooterSubagentState,
FooterSubagentTab,
FooterView,
RunCommand,
RunInput,
RunPrompt,
RunProvider,
RunTuiConfig,
StreamCommit,
} from "@/cli/cmd/run/types"
import { RunQuestionBody } from "@/cli/cmd/run/footer.question"
import { RejectField } from "@/cli/cmd/run/footer.permission"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
function bindings(...keys: string[]) {
return keys.map((key) => ({ key }))
}
const keybinds: FooterKeybinds = {
leader: "ctrl+x",
leaderTimeout: 2000,
commandList: bindings("ctrl+p"),
variantCycle: bindings("ctrl+t"),
interrupt: bindings("escape"),
historyPrevious: bindings("up"),
historyNext: bindings("down"),
inputClear: bindings("ctrl+c"),
inputSubmit: bindings("return"),
inputNewline: bindings("shift+return,ctrl+return,alt+return,ctrl+j"),
}
const tuiConfig = createTuiResolvedConfig()
function command(input: { name: string; description: string; source?: "command" | "mcp" | "skill" }) {
return {
@@ -143,6 +135,128 @@ function subagent(input: {
} satisfies FooterSubagentTab
}
function footerState(input: Partial<FooterState> = {}) {
return createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "gpt-5",
duration: "",
usage: "",
first: false,
interrupt: 0,
exit: 0,
...input,
})[0]
}
async function renderFooter(
input: {
tuiConfig?: RunTuiConfig
commands?: RunCommand[]
theme?: () => RunTheme
onCycle?: () => void
onSubmit?: (prompt: RunPrompt) => boolean
} = {},
) {
const [view] = createSignal<FooterView>({ type: "prompt" })
const [subagents] = createSignal<FooterSubagentState>({ tabs: [], details: {}, permissions: [], questions: [] })
const state = footerState()
const config = input.tuiConfig ?? tuiConfig
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
return (
<OpencodeKeymapProvider keymap={keymap}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
agents={() => []}
resources={() => []}
commands={() => input.commands ?? []}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
state={state}
view={view}
subagent={subagents}
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
tuiConfig={config}
backgroundSubagents={true}
agent="opencode"
onSubmit={input.onSubmit ?? (() => true)}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
onCycle={input.onCycle ?? (() => {})}
onInterrupt={() => false}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
onTerminalWrite={async () => {}}
onTerminalResize={async () => {}}
onTerminalClose={async () => {}}
/>
</OpencodeKeymapProvider>
)
}
const app = await testRender(
() => (
<box width={100} height={8}>
<Harness />
</box>
),
{ width: 100, height: 8, kittyKeyboard: true },
)
return {
...app,
cleanup() {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
offKeymap = undefined
app.renderer.destroy()
},
}
}
test("direct footer updates composer background when theme changes", async () => {
const surface = RGBA.fromHex("#123456")
const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
const app = await renderFooter({ theme })
try {
await app.renderOnce()
const area = app.renderer.root.findDescendantById("run-direct-footer-composer-area") as BoxRenderable
expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts())
setTheme({
...RUN_THEME_FALLBACK,
footer: {
...RUN_THEME_FALLBACK.footer,
surface,
},
})
await app.renderOnce()
expect(area.backgroundColor.toInts()).toEqual(surface.toInts())
} finally {
app.cleanup()
}
})
test("run entry content updates when live commit text changes", async () => {
const [commit, setCommit] = createSignal<StreamCommit>({
kind: "tool",
@@ -203,11 +317,13 @@ test("direct command panel renders grouped command palette", async () => {
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
subagents={subagents}
queued={() => []}
variants={variants}
keybinds={keybinds}
variantCycle="ctrl+t"
onClose={() => {}}
onModel={() => {}}
onSubagent={() => {}}
onQueued={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onCommand={() => {}}
@@ -261,11 +377,13 @@ test("direct command panel shows subagent entry when available", async () => {
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
subagents={subagents}
queued={() => []}
variants={variants}
keybinds={keybinds}
variantCycle="ctrl+t"
onClose={() => {}}
onModel={() => {}}
onSubagent={() => {}}
onQueued={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onCommand={() => {}}
@@ -334,11 +452,158 @@ test("direct subagent panel renders active subagents", async () => {
}
})
test("direct footer shows subagent indicator while prompt is running", async () => {
test("direct queued prompt panel renders pending prompt actions", async () => {
const [prompts] = createSignal([
{ messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } },
])
const app = await testRender(
() => (
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
<RunQueuedPromptSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
prompts={prompts}
onClose={() => {}}
onEdit={() => {}}
onDelete={() => {}}
/>
</box>
),
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Queued prompts")
expect(app.captureCharFrame()).toContain("fix the auth test")
expect(app.captureCharFrame()).toContain("queued")
} finally {
app.renderer.destroy()
}
})
// OpenTUI currently segfaults when the full footer view suite creates several
// keymap-backed test renderers in one process. Re-enable after the runtime fix.
test.skip("direct footer opens command panel through keymap binding", async () => {
const app = await renderFooter()
try {
await app.renderOnce()
app.mockInput.pressKey("p", { ctrl: true })
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Commands")
} finally {
app.cleanup()
}
})
test.skip("direct footer dispatches leader variant binding only when leader is registered", async () => {
const calls: string[] = []
const app = await renderFooter({
tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", variant_cycle: "<leader>t" } }),
onCycle: () => calls.push("cycle"),
})
try {
await app.renderOnce()
app.mockInput.pressKey("t")
expect(calls).toEqual([])
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressKey("t")
expect(calls).toEqual(["cycle"])
} finally {
app.cleanup()
}
})
test("direct footer keeps leader variant binding inactive when leader is disabled", async () => {
const calls: string[] = []
const app = await renderFooter({
tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", variant_cycle: "<leader>t" } }),
onCycle: () => calls.push("cycle"),
})
try {
await app.renderOnce()
app.mockInput.pressKey("t")
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressKey("t")
expect(calls).toEqual([])
} finally {
app.cleanup()
}
})
test("direct footer submits slash autocomplete selections without dispatching shell completions", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
commands: [command({ name: "review", description: "Review code" })],
onSubmit(prompt) {
submits.push(prompt)
return true
},
})
try {
await app.renderOnce()
"/rev".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
"/rev".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressKey("TAB")
await app.renderOnce()
"/re branch".split("").forEach((key) => app.mockInput.pressKey(key))
Array.from({ length: 7 }).forEach(() => app.mockInput.pressKey("ARROW_LEFT"))
app.mockInput.pressKey("v")
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
"/nx".split("").forEach((key) => app.mockInput.pressKey(key))
app.mockInput.pressKey("ARROW_LEFT")
app.mockInput.pressKey("e")
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
"/n scratch".split("").forEach((key) => app.mockInput.pressKey(key))
Array.from({ length: 8 }).forEach(() => app.mockInput.pressKey("ARROW_LEFT"))
app.mockInput.pressKey("e")
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
app.mockInput.pressKey("!")
"/rev".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } },
{ text: "/new ", parts: [] },
{ text: "/new ", parts: [] },
])
expect(app.captureCharFrame()).toContain("/review")
} finally {
app.cleanup()
}
})
test("direct footer shows editable prompts and additional queued work while running", async () => {
const [state] = createSignal<FooterState>({
phase: "running",
status: "",
queue: 0,
queue: 3,
model: "gpt-5",
duration: "",
usage: "",
@@ -353,10 +618,14 @@ test("direct footer shows subagent indicator while prompt is running", async ()
permissions: [],
questions: [],
})
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
const app = await testRender(
() => (
<box width={100} height={8}>
return (
<OpencodeKeymapProvider keymap={keymap}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
@@ -370,8 +639,12 @@ test("direct footer shows subagent indicator while prompt is running", async ()
state={state}
view={view}
subagent={subagents}
theme={RUN_THEME_FALLBACK}
keybinds={keybinds}
queuedPrompts={() => [
{ messageID: "m-queued", partID: "p-queued", prompt: { text: "follow up", parts: [] } },
]}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={tuiConfig}
backgroundSubagents={true}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
@@ -389,19 +662,34 @@ test("direct footer shows subagent indicator while prompt is running", async ()
onTerminalWrite={async () => {}} // kilocode_change
onTerminalResize={async () => {}} // kilocode_change
onTerminalClose={async () => {}} // kilocode_change
onQueuedRemove={async () => true}
/>
</OpencodeKeymapProvider>
)
}
const app = await testRender(
() => (
<box width={160} height={8}>
<Harness />
</box>
),
{
width: 100,
width: 160,
height: 8,
},
)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ↓ to view")
expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • ctrl+b background • 1 queued ctrl+x q")
expect(app.captureCharFrame()).toContain("2 queued")
expect(app.captureCharFrame()).not.toContain("to view")
expect(app.captureCharFrame()).not.toContain("edit/remove")
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
app.renderer.destroy()
}
})
@@ -453,6 +741,122 @@ test("direct question body separates single-select checkmark from label", async
}
})
// OpenTUI currently segfaults while tearing down this textarea-backed keymap renderer.
// Re-enable after the runtime fix.
test.skip("direct custom answer submits through keymap return binding", async () => {
const question = {
id: "question-1",
sessionID: "session-1",
questions: [
{
question: "Which answer should I use?",
header: "Answer",
options: [{ label: "Provided", description: "Use the listed answer." }],
custom: true,
},
],
} satisfies QuestionRequest
const questions: unknown[] = []
let off: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return (
<OpencodeKeymapProvider keymap={keymap}>
<RunQuestionBody
request={question}
theme={RUN_THEME_FALLBACK.footer}
onReply={(input) => {
questions.push(input)
}}
onReject={() => {}}
/>
</OpencodeKeymapProvider>
)
}
const app = await testRender(
() => (
<box width={100} height={18}>
<Harness />
</box>
),
{ width: 100, height: 18, kittyKeyboard: true },
)
try {
await app.renderOnce()
app.mockInput.pressKey("2")
await app.renderOnce()
"typed".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(questions).toEqual([{ requestID: "question-1", answers: [["typed"]] }])
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
off?.()
app.renderer.destroy()
}
})
test("direct permission rejection submits through keymap return binding", async () => {
let text = ""
const submits: string[] = []
let off: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return (
<OpencodeKeymapProvider keymap={keymap}>
<RejectField
theme={RUN_THEME_FALLBACK.footer}
text=""
disabled={false}
onChange={(input) => {
text = input
}}
onConfirm={() => {
submits.push(text)
}}
onCancel={() => {}}
/>
</OpencodeKeymapProvider>
)
}
const app = await testRender(
() => (
<box width={100} height={18}>
<Harness />
</box>
),
{ width: 100, height: 18, kittyKeyboard: true },
)
try {
await app.renderOnce()
"retry".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
expect(app.captureCharFrame()).toContain("retry")
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual(["retry"])
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
off?.()
app.renderer.destroy()
}
})
test("direct model panel renders current model selector", async () => {
const [providers] = createSignal<RunProvider[] | undefined>([provider()])
const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })
@@ -7,32 +7,10 @@ import {
isNewCommand,
mentionTriggerIndex,
movePromptHistory,
printableBinding,
promptCycle,
promptHit,
promptInfo,
promptKeys,
pushPromptHistory,
} from "@/cli/cmd/run/prompt.shared"
import type { RunPrompt } from "@/cli/cmd/run/types"
function bindings(...keys: string[]) {
return keys.map((key) => ({ key }))
}
const keybinds = {
leader: "ctrl+x",
leaderTimeout: 2000,
commandList: bindings("ctrl+p"),
variantCycle: bindings("ctrl+t", "<leader>t"),
interrupt: bindings("escape"),
historyPrevious: bindings("up"),
historyNext: bindings("down"),
inputClear: bindings("ctrl+c"),
inputSubmit: bindings("return"),
inputNewline: bindings("shift+return,ctrl+return,alt+return,ctrl+j"),
}
function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
return { text, parts }
}
@@ -141,39 +119,6 @@ describe("run prompt shared", () => {
expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
})
test("handles direct and leader-based variant cycling", () => {
const keys = promptKeys(keybinds)
expect(promptHit(keys.clear, promptInfo({ name: "c", ctrl: true }))).toBe(true)
expect(promptCycle(false, promptInfo({ name: "x", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
arm: true,
clear: false,
cycle: false,
consume: true,
})
expect(promptCycle(true, promptInfo({ name: "t" }), keys.leaders, keys.cycles)).toEqual({
arm: false,
clear: true,
cycle: true,
consume: true,
})
expect(promptCycle(false, promptInfo({ name: "t", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
arm: false,
clear: false,
cycle: true,
consume: true,
})
})
test("prints bindings with leader substitution and esc normalization", () => {
expect(printableBinding(keybinds.variantCycle.slice(1), "ctrl+x")).toBe("ctrl+x t")
expect(printableBinding(keybinds.interrupt, "ctrl+x")).toBe("esc")
expect(printableBinding([], "ctrl+x")).toBe("")
})
test("recognizes exit commands", () => {
expect(isExitCommand("/exit")).toBe(true)
expect(isExitCommand(" /Quit ")).toBe(true)
@@ -1,8 +1,7 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { KiloClient, type Provider } from "@kilocode/sdk/v2"
import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
import { formatBindings } from "@/cli/cmd/run/keymap.shared"
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot"
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
function model(id: string, providerID: string, context: number, variants?: Record<string, Record<string, never>>) {
@@ -111,35 +110,44 @@ describe("run runtime boot", () => {
}),
)
const result = await resolveFooterKeybinds()
const result = await resolveRunTuiConfig()
expect(result.leader).toBe("ctrl+g")
expect(result.leaderTimeout).toBe(2000)
expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t, alt+t")
expect(formatBindings(result.interrupt, result.leader)).toBe("ctrl+c")
expect(formatBindings(result.historyPrevious, result.leader)).toBe("k")
expect(formatBindings(result.historyNext, result.leader)).toBe("j")
expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+l")
expect(formatBindings(result.inputSubmit, result.leader)).toBe("ctrl+s")
expect(formatBindings(result.inputNewline, result.leader)).toBe("alt+return")
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
expect(result.leader_timeout).toBe(2000)
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"])
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("k")
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("j")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+l")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("ctrl+s")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("alt+return")
})
test("falls back to default keybinds when config load fails", async () => {
test("falls back to default tui keymap config when config load fails", async () => {
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
const result = await resolveFooterKeybinds()
const result = await resolveRunTuiConfig()
expect(result.leader).toBe("ctrl+x")
expect(result.leaderTimeout).toBe(2000)
expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t")
expect(formatBindings(result.interrupt, result.leader)).toBe("esc")
expect(formatBindings(result.historyPrevious, result.leader)).toBe("up")
expect(formatBindings(result.historyNext, result.leader)).toBe("down")
expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+c")
expect(formatBindings(result.inputSubmit, result.leader)).toBe("return")
expect(formatBindings(result.inputNewline, result.leader)).toBe("shift+return, ctrl+return, alt+return, ctrl+j")
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x")
expect(result.leader_timeout).toBe(2000)
expect(result.diff_style).toBe("auto")
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
expect(result.keybinds.get("variant.cycle")?.[0]?.key).toBe("ctrl+t")
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("escape")
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("up")
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
})
test("preserves disabled leader from resolved tui config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(config({ leader: "none" }))
const result = await resolveRunTuiConfig()
expect(result.keybinds.get("leader")).toEqual([])
})
test("reads diff style and falls back to auto", async () => {
@@ -4,6 +4,7 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/
function footer() {
const prompts = new Set<(input: RunPrompt) => void>()
const queuedRemoves = new Set<(messageID: string) => void>()
const closes = new Set<() => void>()
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
@@ -19,6 +20,12 @@ function footer() {
prompts.delete(fn)
}
},
onQueuedRemove(fn) {
queuedRemoves.add(fn)
return () => {
queuedRemoves.delete(fn)
}
},
onClose(fn) {
if (closed) {
fn()
@@ -66,6 +73,9 @@ function footer() {
fn(next)
}
},
removeQueued(messageID: string) {
for (const fn of [...queuedRemoves]) fn(messageID)
},
}
}
@@ -133,6 +143,7 @@ describe("run runtime queue", () => {
text: "hello",
phase: "start",
source: "system",
messageID: expect.any(String),
},
])
})
@@ -215,6 +226,7 @@ describe("run runtime queue", () => {
text: " hello ",
phase: "start",
source: "system",
messageID: expect.any(String),
},
])
})
@@ -250,6 +262,7 @@ describe("run runtime queue", () => {
text: "/fmt bash",
phase: "start",
source: "system",
messageID: expect.any(String),
},
])
ui.api.close()
@@ -289,6 +302,82 @@ describe("run runtime queue", () => {
expect(seen).toEqual(["one", "two"])
})
test("exposes ordinary in-flight prompts for removal before sending", async () => {
const ui = footer()
const turns: RunPrompt[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
turns.push(input)
await gate
},
})
ui.submit("one")
ui.submit("two")
await Promise.resolve()
await Promise.resolve()
expect(turns.map((item) => item.text)).toEqual(["one"])
expect(turns[0]?.messageID).toEqual(expect.any(String))
expect(ui.commits.map((item) => item.text)).toEqual(["one"])
const first = ui.events.find((item) => item.type === "queued.prompts")
const event = ui.events.findLast((item) => item.type === "queued.prompts")
expect(first?.type === "queued.prompts" ? first.prompts : []).toEqual([])
expect(
first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true,
).toBe(false)
expect(ui.events.findLast((item) => item.type === "queue")).toEqual({ type: "queue", queue: 1 })
expect(event?.type === "queued.prompts" ? event.prompts.map((item) => item.prompt.text) : []).toEqual(["two"])
if (event?.type === "queued.prompts") ui.removeQueued(event.prompts[0]!.messageID)
await Promise.resolve()
wake?.()
ui.api.close()
await task
expect(turns.map((item) => item.text)).toEqual(["one"])
})
test("removing one managed queued prompt preserves the others", async () => {
const ui = footer()
const turns: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
turns.push(input.text)
if (input.text === "active") await gate
if (input.text === "queued three") ui.api.close()
},
})
ui.submit("active")
ui.submit("queued one")
ui.submit("queued two")
ui.submit("queued three")
await Promise.resolve()
await Promise.resolve()
const event = ui.events.findLast((item) => item.type === "queued.prompts")
if (event?.type === "queued.prompts") {
const second = event.prompts.find((item) => item.prompt.text === "queued two")
if (second) ui.removeQueued(second.messageID)
}
wake?.()
await task
expect(turns).toEqual(["active", "queued one", "queued three"])
})
test("drains a prompt queued during an in-flight turn", async () => {
const ui = footer()
const seen: string[] = []
@@ -1,8 +1,9 @@
import { afterEach, expect, test } from "bun:test"
import type { ToolPart } from "@kilocode/sdk/v2"
import { RGBA, SyntaxStyle } from "@opentui/core"
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme"
import type { StreamCommit } from "@/cli/cmd/run/types"
type ClaimedCommit = {
@@ -62,6 +63,8 @@ async function setup(
input: {
width?: number
wrote?: boolean
theme?: RunTheme
onThemeRelease?: (theme: RunTheme) => void
} = {},
) {
const out = await createTestRenderer({
@@ -78,9 +81,10 @@ async function setup(
return {
renderer: out.renderer,
scrollback: new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
scrollback: new RunScrollbackStream(out.renderer, input.theme ?? RUN_THEME_FALLBACK, {
treeSitterClient,
wrote: input.wrote ?? false,
onThemeRelease: input.onThemeRelease,
}),
}
}
@@ -107,6 +111,79 @@ function reasoning(text: string, phase: StreamCommit["phase"] = "progress"): Str
}
}
test("theme swaps restyle active reasoning without resetting the stream", async () => {
const previousSyntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } })
const nextSyntax = SyntaxStyle.fromStyles({ default: { fg: "#abcdef" } })
const released: RunTheme[] = []
const previous = {
...RUN_THEME_FALLBACK,
block: {
...RUN_THEME_FALLBACK.block,
subtleSyntax: previousSyntax,
},
}
const next = {
...RUN_THEME_FALLBACK,
block: {
...RUN_THEME_FALLBACK.block,
subtleSyntax: nextSyntax,
},
}
const out = await setup({ theme: previous, onThemeRelease: (theme) => released.push(theme) })
try {
await out.scrollback.append(reasoning("before"))
expect(activeSyntax(out.scrollback)).toBe(previousSyntax)
out.scrollback.setTheme(next)
expect(activeSyntax(out.scrollback)).toBe(nextSyntax)
expect(released).toEqual([])
await out.scrollback.append(reasoning("after"))
expect(activeSyntax(out.scrollback)).toBe(nextSyntax)
expect(released).toEqual([previous])
} finally {
out.scrollback.destroy()
destroy(claim(out.renderer))
previousSyntax.destroy()
nextSyntax.destroy()
}
})
function activeSyntax(scrollback: RunScrollbackStream) {
const entry = Reflect.get(scrollback, "active") as { renderable?: { syntaxStyle?: SyntaxStyle } } | undefined
return entry?.renderable?.syntaxStyle
}
test("theme swaps preserve streamed markdown parser state", async () => {
const out = await setup()
const next = {
...RUN_THEME_FALLBACK,
footer: {
...RUN_THEME_FALLBACK.footer,
surface: RGBA.fromHex("#123456"),
},
}
try {
await out.scrollback.append(assistant("```ts\nconst answer ="))
out.scrollback.setTheme(next)
await out.scrollback.append(assistant(" 42\n```"))
await out.scrollback.complete()
const commits = claim(out.renderer)
try {
const output = render(commits)
expect(output).toContain("const answer = 42")
expect(output).not.toContain("```")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})
function user(text: string): StreamCommit {
return {
kind: "user",
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { replaySession } from "@/cli/cmd/run/session-replay"
import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
function userMessage(id: string, text: string): SessionMessages[number] {
@@ -156,4 +156,301 @@ describe("run session replay", () => {
}),
)
})
test("merges failed local rows ahead of later persisted prompts", () => {
const persisted = {
kind: "user",
text: "successful",
phase: "start",
source: "system",
messageID: "msg-user-2",
} as const
const failed = {
kind: "user",
text: "failed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const error = {
kind: "error",
text: "network unavailable",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
expect(
replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }]),
).toEqual([failed, error, persisted])
})
test("retains local errors but not duplicate local prompts once a prompt persists", () => {
const persisted = {
kind: "user",
text: "failed after persistence",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const error = {
kind: "error",
text: "connection closed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "failed after persistence")],
[persisted],
[{ commit: persisted }, { commit: error }],
),
).toEqual([persisted, error])
})
test("keeps a local turn failure below assistant output already visible for that turn", () => {
const first = {
kind: "user",
text: "start",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const answer = {
kind: "assistant",
text: "partial answer",
phase: "progress",
source: "assistant",
messageID: "msg-assistant-1",
} as const
const error = {
kind: "error",
text: "stream failed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const second = {
kind: "user",
text: "retry",
phase: "start",
source: "system",
messageID: "msg-user-2",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "start"), userMessage("msg-user-2", "retry")],
[first, answer, second],
[
{
commit: error,
after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-assistant-1" },
},
],
),
).toEqual([first, answer, error, second])
})
test("keeps a local failure above assistant output received after the failure", () => {
const first = {
kind: "user",
text: "start",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const error = {
kind: "error",
text: "request failed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const late = {
kind: "assistant",
text: "late answer",
phase: "progress",
source: "assistant",
messageID: "msg-assistant-1",
} as const
expect(replayLocalRows([userMessage("msg-user-1", "start")], [first, late], [{ commit: error }])).toEqual([
first,
error,
late,
])
})
test("inserts a local failure between persisted output chunks spanning that failure", () => {
const first = {
kind: "user",
text: "start",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const complete = {
kind: "assistant",
text: "before after",
phase: "progress",
source: "assistant",
messageID: "msg-assistant-1",
partID: "part-1",
} as const
const error = {
kind: "error",
text: "stream failed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "start")],
[first, complete],
[
{
commit: error,
after: {
kind: "assistant",
text: "before ",
phase: "progress",
messageID: "msg-assistant-1",
partID: "part-1",
visible: "before ",
},
},
],
),
).toEqual([first, { ...complete, text: "before " }, error, { ...complete, text: "after" }])
})
test("places an unpersisted failed prompt before live output from that turn", () => {
const prompt = {
kind: "user",
text: "start",
phase: "start",
source: "system",
messageID: "msg-1",
} as const
const answer = {
kind: "assistant",
text: "partial answer",
phase: "progress",
source: "assistant",
messageID: "msg-2",
} as const
const error = {
kind: "error",
text: "stream failed",
phase: "start",
source: "system",
messageID: "msg-1",
} as const
expect(
replayLocalRows(
[],
[answer],
[
{ commit: prompt },
{
commit: error,
after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" },
},
],
),
).toEqual([prompt, answer, error])
})
test("anchors a failure after the visible start of a tool that later completes", () => {
const prompt = {
kind: "user",
text: "run ls",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const running = {
kind: "tool",
text: "running bash",
phase: "start",
source: "tool",
messageID: "msg-assistant-1",
partID: "part-tool-1",
toolState: "running",
} as const
const completed = {
kind: "tool",
text: "file.txt",
phase: "final",
source: "tool",
messageID: "msg-assistant-1",
partID: "part-tool-1",
toolState: "completed",
} as const
const error = {
kind: "error",
text: "connection lost",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "run ls")],
[prompt, running, completed],
[
{
commit: error,
after: {
kind: "tool",
text: "running bash",
phase: "start",
messageID: "msg-assistant-1",
partID: "part-tool-1",
toolState: "running",
},
},
],
),
).toEqual([prompt, running, error, completed])
})
test("retains an unpersisted local diagnostic before later persisted prompts", () => {
const first = {
kind: "user",
text: "before",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const error = {
kind: "error",
text: "failed to start new session",
phase: "start",
source: "system",
messageID: "msg-user-2",
} as const
const second = {
kind: "user",
text: "after",
phase: "start",
source: "system",
messageID: "msg-user-3",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")],
[first, second],
[{ commit: error }],
),
).toEqual([first, error, second])
})
})
@@ -9,6 +9,7 @@ function footer() {
const api: FooterApi = {
isClosed: false,
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose: () => () => {},
event: (next) => {
events.push(next)
@@ -1,7 +1,8 @@
// kilocode_change - new file
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { KiloClient, type GlobalEvent } from "@kilocode/sdk/v2"
import { createSessionTransport } from "@/cli/cmd/run/stream.transport"
import type { FooterApi, FooterEvent, RunFilePart, StreamCommit } from "@/cli/cmd/run/types"
import type { FooterApi, FooterEvent, LocalReplayRow, RunFilePart, StreamCommit } from "@/cli/cmd/run/types"
type SdkEvent = GlobalEvent["payload"]
type EventStream = AsyncGenerator<SdkEvent, void, unknown>
@@ -11,6 +12,7 @@ type SessionChild = NonNullable<Awaited<ReturnType<KiloClient["session"]["childr
type SessionToolPart = Extract<SessionMessage["parts"][number], { type: "tool" }>
type SessionStatusMap = NonNullable<Awaited<ReturnType<KiloClient["session"]["status"]>>["data"]>
type TextPart = Extract<SessionMessage["parts"][number], { type: "text" }>
type ReasoningPart = Extract<SessionMessage["parts"][number], { type: "reasoning" }>
afterEach(() => {
mock.restore()
@@ -87,16 +89,19 @@ function assistant(id: string, sessionID = "session-1"): SdkEvent {
return {
id: `evt-${id}`,
type: "sync",
name: "message.updated.1",
seq: 1,
aggregateID: "sessionID",
data: {
sessionID,
info: assistantMessage({
syncEvent: {
type: "message.updated.1",
id: `evt-${id}`,
seq: 1,
aggregateID: sessionID,
data: {
sessionID,
id,
parts: [],
}).info,
info: assistantMessage({
sessionID,
id,
parts: [],
}).info,
},
},
}
}
@@ -293,10 +298,36 @@ function textUpdated(part: TextPart): SdkEvent {
return {
id: `evt-${part.id}-updated`,
type: "sync",
name: "message.part.updated.1",
seq: 1,
aggregateID: "sessionID",
data: {
syncEvent: {
type: "message.part.updated.1",
id: `evt-${part.id}-updated`,
seq: 1,
aggregateID: part.sessionID,
data: {
sessionID: part.sessionID,
part,
time: 1,
},
},
}
}
function reasoningPart(id: string, messageID: string, text: string): ReasoningPart {
return {
id,
sessionID: "session-1",
messageID,
type: "reasoning",
text,
time: { start: 1 },
}
}
function reasoningUpdated(part: ReasoningPart): SdkEvent {
return {
id: `evt-${part.id}-updated`,
type: "message.part.updated",
properties: {
sessionID: part.sessionID,
part,
time: 1,
@@ -308,13 +339,16 @@ function toolUpdated(part: SessionToolPart): SdkEvent {
return {
id: `evt-${part.id}-updated`,
type: "sync",
name: "message.part.updated.1",
seq: 1,
aggregateID: "sessionID",
data: {
sessionID: part.sessionID,
part,
time: 1,
syncEvent: {
type: "message.part.updated.1",
id: `evt-${part.id}-updated`,
seq: 1,
aggregateID: part.sessionID,
data: {
sessionID: part.sessionID,
part,
time: 1,
},
},
}
}
@@ -367,6 +401,7 @@ function footer(fn?: (commit: StreamCommit) => void) {
return closed
},
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose: () => () => {},
event(next) {
events.push(next)
@@ -726,6 +761,444 @@ describe("run stream transport", () => {
}
})
test("rebuilds session output on resize and continues live deltas from replayed state", async () => {
const src = eventFeed()
const ui = footer()
let calls = 0
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async () => {
calls += 1
if (calls === 1) {
return ok([])
}
return ok([
assistantMessage({
sessionID: "session-1",
id: "msg-1",
parts: [textPart("text-1", "msg-1", "Hello")],
}),
])
},
}),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
})
const localRows: LocalReplayRow[] = [
{ commit: { kind: "user", text: "pending prompt", phase: "start", source: "system", messageID: "msg-pending" } },
]
const reset = mock(() => {
localRows.push({
commit: {
kind: "user",
text: "sent during reset",
phase: "start",
source: "system",
messageID: "msg-during-reset",
},
})
return Promise.resolve()
})
try {
expect(
await transport.replayOnResize({
localRows: () => localRows,
reset,
}),
).toBe(true)
expect(reset).toHaveBeenCalledTimes(1)
expect(ui.commits).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "assistant", text: "Hello" }),
expect.objectContaining({ kind: "user", text: "sent during reset", messageID: "msg-during-reset" }),
]),
)
src.push(textUpdated(textPart("text-1", "msg-1", "Hello world")))
await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === " world"))
expect(ui.commits.filter((commit) => commit.kind === "assistant").map((commit) => commit.text)).toEqual([
"Hello",
" world",
])
} finally {
src.close()
await transport.close()
}
})
test("coalesces active resize requests into one trailing replay", async () => {
const src = eventFeed()
const ui = footer()
const firstReset = defer()
const resetA = mock(() => firstReset.promise)
const resetB = mock(() => Promise.resolve())
const resetC = mock(() => Promise.resolve())
const transport = await createSessionTransport({
sdk: sdk({ stream: src.stream }),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
})
try {
const active = transport.replayOnResize({ localRows: () => [], reset: resetA })
await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined))
expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false)
expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false)
expect(resetB).not.toHaveBeenCalled()
firstReset.resolve()
expect(await active).toBe(true)
expect(resetA).toHaveBeenCalledTimes(1)
expect(resetB).not.toHaveBeenCalled()
expect(resetC).toHaveBeenCalledTimes(1)
} finally {
src.close()
await transport.close()
}
})
test("keeps coalescing resize requests while buffered events drain", async () => {
const src = eventFeed()
const ui = footer()
const firstReset = defer()
const statusGate = defer()
const statusStarted = defer()
let blockStatus = false
const trace = mock((_type: string, _data?: unknown) => {})
const resetA = mock(() => firstReset.promise)
const resetB = mock(() => Promise.resolve())
const resetC = mock(() => Promise.resolve())
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
status: async () => {
if (blockStatus) {
statusStarted.resolve()
await statusGate.promise
}
return ok(statusMap(true))
},
}),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
trace: { write: trace },
})
const turn = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "active", parts: [] },
files: [],
includeFiles: false,
})
try {
await waitFor(() => ui.events.find((event) => event.type === "turn.wait"))
const active = transport.replayOnResize({ localRows: () => [], reset: resetA })
await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined))
blockStatus = true
src.push(busy())
src.push(idle())
await waitFor(() => (trace.mock.calls.filter((call) => call[0] === "recv.event").length >= 2 ? true : undefined))
expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false)
firstReset.resolve()
await Promise.race([
statusStarted.promise,
Bun.sleep(1_000).then(() => {
throw new Error("timed out waiting for buffered status drain")
}),
])
expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false)
expect(resetC).not.toHaveBeenCalled()
blockStatus = false
statusGate.resolve()
expect(
await Promise.race([
active,
Bun.sleep(1_000).then(() => {
throw new Error("timed out waiting for trailing resize replay")
}),
]),
).toBe(true)
expect(resetB).not.toHaveBeenCalled()
expect(resetC).toHaveBeenCalledTimes(1)
} finally {
src.close()
await transport.close()
await turn
}
})
test("preserves assistant deltas not yet persisted when replaying during a live stream", async () => {
const src = eventFeed()
const ui = footer()
let calls = 0
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async () => {
calls += 1
if (calls === 1) {
return ok([])
}
return ok([
assistantMessage({
sessionID: "session-1",
id: "msg-live",
parts: [textPart("text-live", "msg-live", "")],
}),
])
},
}),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
})
try {
src.push(assistant("msg-live"))
src.push(textUpdated(textPart("text-live", "msg-live", "")))
src.push(textDelta("msg-live", "text-live", "Hello"))
await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello"))
ui.commits.length = 0
expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true)
src.push(textDelta("msg-live", "text-live", "Hello"))
src.push(
textUpdated({
...textPart("text-live", "msg-live", "HelloHello"),
time: { start: 1, end: 2 },
}),
)
await waitFor(() =>
ui.commits.filter((commit) => commit.kind === "assistant" && commit.text === "Hello").length === 2
? true
: undefined,
)
expect(
ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text),
).toEqual(["Hello", "Hello"])
} finally {
src.close()
await transport.close()
}
})
test("preserves the display prefix for active reasoning restored during replay", async () => {
const src = eventFeed()
const ui = footer()
let calls = 0
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async () => {
calls += 1
if (calls === 1) {
return ok([])
}
return ok([
assistantMessage({
sessionID: "session-1",
id: "msg-thinking",
parts: [reasoningPart("thinking-1", "msg-thinking", "")],
}),
])
},
}),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
})
try {
src.push(assistant("msg-thinking"))
src.push(reasoningUpdated(reasoningPart("thinking-1", "msg-thinking", "")))
src.push(textDelta("msg-thinking", "thinking-1", "plan"))
await waitFor(() => ui.commits.find((commit) => commit.kind === "reasoning" && commit.text === "Thinking: plan"))
ui.commits.length = 0
expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true)
expect(ui.commits.filter((commit) => commit.kind === "reasoning").map((commit) => commit.text)).toEqual([
"Thinking: plan",
])
} finally {
src.close()
await transport.close()
}
})
test("does not overlay stale active text when persistence completes during replay", async () => {
const src = eventFeed()
const ui = footer()
let calls = 0
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async () => {
calls += 1
if (calls === 1) {
return ok([])
}
return ok([
assistantMessage({
sessionID: "session-1",
id: "msg-finished",
parts: [
{
...textPart("text-finished", "msg-finished", "Hello"),
time: { start: 1, end: 2 },
},
],
}),
])
},
}),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
})
try {
src.push(assistant("msg-finished"))
src.push(textUpdated(textPart("text-finished", "msg-finished", "")))
src.push(textDelta("msg-finished", "text-finished", "Hello"))
await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello"))
ui.commits.length = 0
expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true)
expect(
ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text),
).toEqual(["Hello"])
} finally {
src.close()
await transport.close()
}
})
test("does not clear the terminal when resize replay snapshot fetch fails", async () => {
const src = eventFeed()
const ui = footer()
let calls = 0
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async () => {
calls += 1
if (calls === 1) {
return ok([])
}
throw new Error("snapshot failed")
},
}),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
})
const reset = mock(() => Promise.resolve())
try {
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
expect(reset).not.toHaveBeenCalled()
expect(ui.commits).toEqual([])
} finally {
src.close()
await transport.close()
}
})
test("disables resize replay for the session after terminal reset fails", async () => {
const src = eventFeed()
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({ stream: src.stream }),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
})
const reset = mock(() => Promise.reject(new Error("clear failed")))
try {
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
expect(reset).toHaveBeenCalledTimes(1)
expect(ui.commits).toContainEqual({
kind: "error",
text: "resize replay failed; disabled for this session",
phase: "start",
source: "system",
})
} finally {
src.close()
await transport.close()
}
})
test("disables resize replay when rebuilding scrollback fails after terminal reset", async () => {
const src = eventFeed()
const ui = footer()
let cleared = false
const idle = ui.api.idle
ui.api.idle = () => (cleared ? Promise.reject(new Error("render failed")) : idle())
const transport = await createSessionTransport({
sdk: sdk({ stream: src.stream }),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
})
const reset = mock(() => {
cleared = true
return Promise.resolve()
})
try {
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
expect(reset).toHaveBeenCalledTimes(1)
expect(ui.commits).toContainEqual({
kind: "error",
text: "resize replay failed; disabled for this session",
phase: "start",
source: "system",
})
} finally {
src.close()
await transport.close()
}
})
test("drops completed historical subagent tabs during bootstrap", async () => {
const src = eventFeed()
const ui = footer()
@@ -82,6 +82,43 @@ test("returns syntax styles and indexed splash colors", async () => {
}
})
test("uses refreshed background brightness when cached renderer mode is stale", async () => {
const colors = terminalColors({
defaultBackground: "#fbf1c7",
defaultForeground: "#3c3836",
})
const stale = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
try {
expect(expectRgba(stale.footer.surface).toInts()).toEqual(expectRgba(light.footer.surface).toInts())
} finally {
stale.block.syntax?.destroy()
stale.block.subtleSyntax?.destroy()
light.block.syntax?.destroy()
light.block.subtleSyntax?.destroy()
}
})
test("keeps renderer mode when refreshed default background is unavailable", async () => {
const colors = {
...terminalColors(),
defaultBackground: null,
palette: ["#000000", ...terminalColors().palette.slice(1)],
}
const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
const dark = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
try {
expect(expectRgba(light.footer.surface).toInts()).not.toEqual(expectRgba(dark.footer.surface).toInts())
} finally {
light.block.syntax?.destroy()
light.block.subtleSyntax?.destroy()
dark.block.syntax?.destroy()
dark.block.subtleSyntax?.destroy()
}
})
test("keeps dark surfaces neutral on saturated backgrounds", () => {
const theme = resolveTheme(
generateSystem(
@@ -1,6 +1,6 @@
import path from "path"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { describe, expect, test } from "bun:test"
import { Effect, FileSystem, Layer } from "effect"
import { Global } from "@opencode-ai/core/global"
@@ -98,7 +98,7 @@ function userMessage(
}
}
const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, NodeFileSystem.layer))
const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, NodeFileSystem.layer))
function remap(root: string, file: string) {
if (file === Global.Path.state) {
@@ -114,16 +114,16 @@ function remap(root: string, file: string) {
function remappedFs(root: string) {
return Layer.effect(
AppFileSystem.Service,
FSUtil.Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return AppFileSystem.Service.of({
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
readJson: (file) => fs.readJson(remap(root, file)),
writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode),
})
}),
).pipe(Layer.provide(AppFileSystem.defaultLayer))
).pipe(Layer.provide(FSUtil.defaultLayer))
}
describe("run variant shared", () => {
@@ -160,7 +160,7 @@ describe("run variant shared", () => {
it.live("reads and writes saved variants through a runtime-backed app fs layer", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const root = yield* filesys.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
@@ -197,7 +197,7 @@ describe("run variant shared", () => {
it.live("repairs malformed saved variant state on the next write", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const root = yield* filesys.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
@@ -52,3 +52,21 @@ exports[`TUI inline tool wrapping keeps separation after a padded user message 1
✱ Grep "export const KILO_DB|KILO_DB|KILO_DEV|Global\\.Path\\.
data|data =" in packages/opencode/src (115 matches)"
`;
exports[`TUI inline tool wrapping separates a contiguous subagent group from inline tools 1`] = `
" ✱ Grep "Task" (2 matches)
⠙ Explore Task — Inspect active task spacing
✓ General Task — Confirm completed task spacing
↳ 1 toolcall · 501ms
→ Read src/cli/cmd/tui/routes/session/index.tsx"
`;
exports[`TUI inline tool wrapping separates a subagent group after an expanded read 1`] = `
" → Read src/cli/cmd/tui/routes/session/index.tsx
↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx
✓ Explore Task — Inspect active task spacing
↳ 1 toolcall · 501ms"
`;
@@ -3,25 +3,114 @@ import { expect, test } from "bun:test"
import path from "path"
import { mkdir } from "fs/promises"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import type { DiffRenderable, Renderable, ScrollBoxRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import { Global } from "@opencode-ai/core/global"
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@kilocode/plugin/tui"
import type { Session } from "@kilocode/sdk/v2"
import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
import { TuiKeybind } from "../../../src/cli/cmd/tui/config/keybind"
import { OpencodeKeymapProvider } from "../../../src/cli/cmd/tui/keymap"
import diffViewerPlugin from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("closing the diff viewer returns to the route it opened from", async () => {
const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } }
const viewer = await renderDiffViewer([])
try {
expect(viewer.current()).toEqual({
name: "diff",
params: { mode: "git", sessionID: "session-1", returnRoute: startRoute },
})
expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "git", context: 12 })
expect(viewer.commands.has("diff.close")).toBe(true)
viewer.commands.get("diff.close")!.run?.({} as never)
expect(viewer.current()).toEqual(startRoute)
} finally {
viewer.app.renderer.destroy()
}
})
test("brackets navigate diff hunks", async () => {
const viewer = await renderDiffViewer(
[
{
file: "src/file.ts",
additions: 3,
deletions: 3,
status: "modified",
patch: `--- a/src/file.ts
+++ b/src/file.ts
@@ -1,3 +1,3 @@
const first = true
-const oldFirst = true
+const newFirst = true
const afterFirst = true
@@ -20,3 +20,3 @@
const second = true
-const oldSecond = true
+const newSecond = true
const afterSecond = true
@@ -40,3 +40,3 @@
const third = true
-const oldThird = true
+const newThird = true
const afterThird = true`,
},
],
12,
)
try {
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
await viewer.app.waitFor(() => Boolean(findRenderable(viewer.app.renderer.root, "diff-viewer-patches")))
await viewer.app.flush()
const scroll = findRenderable(viewer.app.renderer.root, "diff-viewer-patches") as ScrollBoxRenderable
const diff = findRenderable(viewer.app.renderer.root, "diff-viewer-patch-0") as DiffRenderable
expect(diff.getHunkRowOffsets()).toEqual([0, 4, 8])
const initial = scroll.scrollTop
expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
await viewer.app.renderOnce()
const first = scroll.scrollTop
expect(first).toBeGreaterThan(initial)
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
await viewer.app.renderOnce()
const second = scroll.scrollTop
expect(second).toBeGreaterThan(first)
viewer.commands.get("diff.previous_hunk")!.run?.({} as never)
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first)
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(second)
scroll.scrollTo(initial)
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first)
} finally {
viewer.app.renderer.destroy()
}
})
async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
const commands = new Map<
string,
NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
>()
let current = startRoute
let renderDiff: TuiRouteDefinition["render"] | undefined
let vcsDiffInput: unknown
const config = createTuiResolvedConfig()
await mkdir(Global.Path.state, { recursive: true })
await Bun.write(path.join(Global.Path.state, "kv.json"), "{}")
@@ -36,9 +125,19 @@ test("closing the diff viewer returns to the route it opened from", async () =>
const base = createTuiPluginApi({
keymap,
client: {
vcs: { diff: async () => ({ data: [] }) },
vcs: {
diff: async (input: unknown) => {
vcsDiffInput = input
return { data: vcsDiff }
},
},
session: { diff: async () => ({ data: [] }) },
} as unknown as TuiPluginApi["client"],
state: {
session: {
get: () => session,
},
},
})
const api = {
...base,
@@ -61,7 +160,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
return (
<OpencodeKeymapProvider keymap={keymap}>
<TuiConfigProvider config={createTuiResolvedConfig()}>
<TuiConfigProvider config={config}>
<KVProvider>
<ThemeProvider mode="dark">
{renderDiff?.({ params: "params" in current ? current.params : undefined })}
@@ -72,18 +171,38 @@ test("closing the diff viewer returns to the route it opened from", async () =>
)
}
const app = await testRender(() => <Harness />, { width: 80, height: 20 })
try {
await waitForCommand(app, commands, "diff.close")
expect(current).toEqual({ name: "diff", params: { mode: "git", sessionID: "session-1", returnRoute: startRoute } })
expect(commands.has("diff.close")).toBe(true)
commands.get("diff.close")!.run?.({} as never)
expect(current).toEqual(startRoute)
} finally {
app.renderer.destroy()
const app = await testRender(() => <Harness />, { width: 80, height })
await waitForCommand(app, commands, "diff.close")
return {
app,
commands,
current: () => current,
vcsDiffInput: () => vcsDiffInput,
}
})
}
const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } }
function findRenderable(root: Renderable, id: string): Renderable | undefined {
if (root.id === id) return root
return root
.getChildren()
.map((child) => findRenderable(child, id))
.find(Boolean)
}
const session = {
id: "session-1",
slug: "session-1",
projectID: "project-1",
directory: "/repo/session",
title: "Session",
version: "1",
time: {
created: 0,
updated: 0,
},
} satisfies Session
async function waitForCommand(
app: Awaited<ReturnType<typeof testRender>>,
@@ -1,7 +1,13 @@
import { afterEach, describe, expect, test } from "bun:test"
import { For } from "solid-js"
import { testRender, type JSX } from "@opentui/solid"
import { InlineToolRow } from "../../../src/cli/cmd/tui/routes/session/index"
import {
formatCompletedSubagentDetail,
formatSubagentRetry,
formatSubagentTitle,
formatSubagentToolcalls,
InlineToolRow,
} from "../../../src/cli/cmd/tui/routes/session/index"
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
@@ -86,6 +92,41 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" })
)
}
function SubagentGroupFixture() {
return (
<box flexDirection="column" width={72}>
<InlineToolRow id="tool-inline-before" icon="✱" complete={true} pending="">
Grep "Task" (2 matches)
</InlineToolRow>
<InlineToolRow id="tool-inline-subagent-one" icon="⠙" complete={true} pending="" subagent={true}>
Explore Task — Inspect active task spacing
</InlineToolRow>
<InlineToolRow id="tool-inline-subagent-two" icon="✓" complete={true} pending="" subagent={true}>
{"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
</InlineToolRow>
<InlineToolRow id="tool-inline-after" icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
</box>
)
}
function LoadedReadBeforeSubagentFixture() {
return (
<box flexDirection="column" width={72}>
<InlineToolRow id="tool-inline-read" icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
<box id="tool-inline-loaded-read-child" paddingLeft={3}>
<text paddingLeft={3}>↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx</text>
</box>
<InlineToolRow id="tool-inline-subagent-after-read" icon="✓" complete={true} pending="" subagent={true}>
{"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
</InlineToolRow>
</box>
)
}
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
testSetup = await testRender(component, options)
await testSetup.renderOnce()
@@ -101,6 +142,24 @@ async function renderFrame(component: () => JSX.Element, options: { width: numbe
}
describe("TUI inline tool wrapping", () => {
test("formats completed subagent toolcall details", () => {
expect(formatCompletedSubagentDetail(0, "501ms")).toBe("501ms")
expect(formatCompletedSubagentDetail(1, "501ms")).toBe("1 toolcall · 501ms")
expect(formatCompletedSubagentDetail(2, "501ms")).toBe("2 toolcalls · 501ms")
expect(formatSubagentToolcalls(0)).toBe("0 toolcalls")
})
test("keeps background state attached to the subagent identity", () => {
expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Task — Inspect renderer")
expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
"Explore Task (background) — Inspect renderer",
)
})
test("keeps retry status ahead of wrapping messages", () => {
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
})
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
expect(await renderFrame(() => <Fixture />, { width: 72, height: 12 })).toMatchSnapshot()
})
@@ -116,4 +175,12 @@ describe("TUI inline tool wrapping", () => {
test("keeps separation after a padded user message", async () => {
expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
})
test("separates a contiguous subagent group from inline tools", async () => {
expect(await renderFrame(() => <SubagentGroupFixture />, { width: 72, height: 10 })).toMatchSnapshot()
})
test("separates a subagent group after an expanded read", async () => {
expect(await renderFrame(() => <LoadedReadBeforeSubagentFixture />, { width: 72, height: 8 })).toMatchSnapshot()
})
})
@@ -0,0 +1,568 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { Event, GlobalEvent } from "@kilocode/sdk/v2"
import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project" // kilocode_change
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { SyncProviderV2, useSyncV2 } from "../../../src/cli/cmd/tui/context/sync-v2"
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
// kilocode_change start - live events are filtered by the resolved project ID
function synced(ready: () => void) {
const project = useProject()
onMount(async () => {
await project.sync()
ready()
})
}
// kilocode_change end
function global(payload: Event): GlobalEvent {
return { directory, project: "proj_test", payload }
}
function emitTwice(events: ReturnType<typeof createEventSource>, payload: Event) {
const event = global(payload)
events.emit(event)
events.emit(event)
}
test("sync v2 settles pending tools when a live failure arrives", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
synced(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
})
emitTwice(events, {
id: "evt_model_1",
type: "session.next.model.switched",
properties: {
sessionID: "session-1",
messageID: "msg_model_1",
timestamp: 0,
model: { id: "model-1", providerID: "provider-1" },
},
})
emitTwice(events, {
id: "evt_step_started_1",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 1,
agent: "build",
model: { id: "model-1", providerID: "provider-1" },
},
})
emitTwice(events, {
id: "evt_input_1",
type: "session.next.tool.input.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 2,
callID: "call-1",
name: "bash",
},
})
emitTwice(events, {
id: "evt_called_1",
type: "session.next.tool.called",
properties: {
sessionID: "session-1",
timestamp: 2,
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
tool: "bash",
input: {},
provider: { executed: false, metadata: { fake: { call: true } } },
},
})
emitTwice(events, {
id: "evt_failed_1",
type: "session.next.tool.failed",
properties: {
sessionID: "session-1",
timestamp: 3,
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
error: { type: "unknown", message: "aborted" },
provider: { executed: false, metadata: { fake: { result: true } } },
},
})
await wait(() => {
const assistant = sync.session.message.fromSession("session-1")[0]
return (
assistant?.type === "assistant" &&
assistant.content[0]?.type === "tool" &&
assistant.content[0].state.status === "error"
)
})
const assistant = sync.session.message.fromSession("session-1")[0]
expect(assistant?.type).toBe("assistant")
if (assistant?.type !== "assistant") return
expect(assistant.id).toBe("msg_explicit_assistant_9")
const tool = assistant.content[0]
expect(tool?.type).toBe("tool")
if (tool?.type !== "tool") return
expect(tool.state.status).toBe("error")
if (tool.state.status !== "error") return
expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
expect(tool.state.input).toEqual({})
expect(tool.state.structured).toEqual({})
expect(tool.state.content).toEqual([])
expect(tool.provider).toEqual({
executed: false,
metadata: { fake: { call: true } },
resultMetadata: { fake: { result: true } },
})
expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
"assistant",
"model-switched",
"agent-switched",
])
} finally {
app.renderer.destroy()
}
})
test("sync v2 renders admitted prompts only after promotion", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
synced(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_admitted_1",
type: "session.next.prompt.admitted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 0,
prompt: { text: "hello" },
delivery: "steer",
},
})
expect(sync.session.message.fromSession("session-1")).toEqual([])
emitTwice(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 1,
prompt: { text: "hello" },
timeCreated: 0,
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
const message = sync.session.message.fromSession("session-1")[0]
expect(message?.type).toBe("user")
if (message?.type !== "user") return
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
} finally {
app.renderer.destroy()
}
})
test("sync v2 renders a promoted prompt when admission was missed", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
synced(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 1,
prompt: { text: "hello" },
timeCreated: 0,
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1")
} finally {
app.renderer.destroy()
}
})
test("sync v2 projects live context updates with their message ID", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
synced(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_context_1",
type: "session.next.context.updated",
properties: {
sessionID: "session-1",
messageID: "msg_context_1",
timestamp: 1,
text: "Updated context",
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
expect(sync.session.message.fromSession("session-1")[0]).toMatchObject({
id: "msg_context_1",
type: "system",
text: "Updated context",
})
} finally {
app.renderer.destroy()
}
})
test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
synced(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
const hydration = sync.session.message.sync("session-1")
emitTwice(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
})
response.resolve(json({ data: [] }))
await hydration
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
["msg_agent_1", "agent-switched"],
])
} finally {
app.renderer.destroy()
}
})
test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
synced(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 1,
prompt: { text: "stale" },
timeCreated: 0,
},
})
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1")
const hydration = sync.session.message.sync("session-1")
emitTwice(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" },
})
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1")
response.resolve(
json({
data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }],
}),
)
await hydration
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
["msg_agent_1", "agent-switched"],
["msg_user_1", "user"],
])
expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" })
} finally {
app.renderer.destroy()
}
})
test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
synced(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_step_older",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_older",
timestamp: 0,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
emitTwice(events, {
id: "evt_step_1",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_old",
timestamp: 1,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old")
const hydration = sync.session.message.sync("session-1")
emitTwice(events, {
id: "evt_text_1",
type: "session.next.text.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_old",
timestamp: 2,
textID: "text-1",
},
})
emitTwice(events, {
id: "evt_text_older",
type: "session.next.text.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_older",
timestamp: 2,
textID: "text-older",
},
})
await wait(() => {
const messages = sync.session.message.fromSession("session-1")
return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text")
})
response.resolve(
json({
data: [
{
id: "msg_assistant_new",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 3 },
},
{
id: "msg_assistant_old",
type: "assistant",
metadata: { source: "snapshot" },
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 1 },
},
],
}),
)
await hydration
emitTwice(events, {
id: "evt_step_late_duplicate",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_old",
timestamp: 1,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([
"msg_assistant_new",
"msg_assistant_old",
"msg_assistant_older",
])
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({
metadata: { source: "snapshot" },
content: [{ type: "text", id: "text-1", text: "" }],
})
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({
content: [{ type: "text", id: "text-older", text: "" }],
})
} finally {
app.renderer.destroy()
}
})
@@ -1,9 +1,10 @@
import { expect, test } from "bun:test"
import * as Log from "@opencode-ai/core/util/log"
import type { TerminalColors } from "@opentui/core"
Log.init({ print: false })
const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme } = await import(
const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme, terminalMode } = await import(
"../../../src/cli/cmd/tui/context/theme"
)
@@ -52,3 +53,27 @@ test("resolveTheme rejects circular color refs", () => {
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
})
function terminalColors(defaultBackground: string | null, palette: Array<string | null> = []): TerminalColors {
return {
palette,
defaultForeground: null,
defaultBackground,
cursorColor: null,
mouseForeground: null,
mouseBackground: null,
tekForeground: null,
tekBackground: null,
highlightBackground: null,
highlightForeground: null,
}
}
test("terminalMode derives mode from refreshed background", () => {
expect(terminalMode(terminalColors("#fbf1c7"))).toBe("light")
expect(terminalMode(terminalColors("#1a1b26"))).toBe("dark")
})
test("terminalMode does not derive mode from ANSI slot zero", () => {
expect(terminalMode(terminalColors(null, ["#000000"]))).toBeUndefined()
})
@@ -113,18 +113,22 @@ describe("useEvent", () => {
}
})
// kilocode_change start - the compatibility stream contains events from every loaded project
test("ignores events for other projects", async () => {
const { app, emit, seen } = await mount()
try {
emit(event(vcs("other"), { directory, project: "proj_other" }))
await Bun.sleep(30)
emit(event(vcs("foreign"), { directory: "/tmp/foreign", project: "proj_foreign" }))
emit(event(vcs("current"), { directory: "/tmp/current", project: projectID }))
expect(seen).toHaveLength(0)
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.properties.branch === "current"))
expect(seen).toEqual([vcs("current")])
} finally {
app.renderer.destroy()
}
})
// kilocode_change end
test("delivers current project events regardless of active workspace", async () => {
const { app, emit, project, seen } = await mount()
+69 -66
View File
@@ -1,4 +1,5 @@
import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { Effect, Exit, Layer, Option } from "effect"
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
@@ -12,7 +13,7 @@ import type { InstanceContext } from "../../src/project/instance-context"
import { Auth } from "../../src/auth"
import { Account } from "../../src/account/account"
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Env } from "../../src/env"
import { Git } from "../../src/git" // kilocode_change
import {
@@ -32,9 +33,10 @@ import fs from "fs/promises"
import os from "os"
import { pathToFileURL } from "url"
import { Global } from "@opencode-ai/core/global"
import { ProjectID } from "../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Filesystem } from "@/util/filesystem"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
@@ -104,7 +106,7 @@ const configLayer = (
Layer.provideMerge(infra),
Layer.provide(NpmTest.noop),
Layer.provide(Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)),
Layer.provideMerge(AppFileSystem.defaultLayer),
Layer.provideMerge(FSUtil.defaultLayer),
)
const layer = configLayer()
@@ -149,7 +151,7 @@ afterEach(async () => {
})
const writeManagedSettingsEffect = (settings: object, filename?: string) =>
AppFileSystem.use.writeWithDirs(path.join(managedConfigDir, filename ?? "kilo.json"), JSON.stringify(settings)) // kilocode_change
FSUtil.use.writeWithDirs(path.join(managedConfigDir, filename ?? "kilo.json"), JSON.stringify(settings)) // kilocode_change
// kilocode_change start
async function writeConfig(dir: string, config: object, name = "kilo.json") {
@@ -161,7 +163,7 @@ const writeConfigEffect = (
dir: string,
config: object,
name = "kilo.json", // kilocode_change
) => AppFileSystem.use.writeWithDirs(path.join(dir, name), JSON.stringify(config))
) => FSUtil.use.writeWithDirs(path.join(dir, name), JSON.stringify(config))
const withInstanceDir = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R>) =>
effect.pipe(
@@ -210,9 +212,7 @@ const withConfigTree = <A, E, R>(
input.global ? writeConfigEffect(global, schemaConfig(input.global)) : undefined,
input.project ? writeConfigEffect(directory, schemaConfig(input.project)) : undefined,
input.local ? writeConfigEffect(path.join(directory, ".kilo"), schemaConfig(input.local)) : undefined, // kilocode_change
].filter(
(effect): effect is Effect.Effect<void, AppFileSystem.Error, AppFileSystem.Service> => effect !== undefined,
),
].filter((effect): effect is Effect.Effect<void, FSUtil.Error, FSUtil.Service> => effect !== undefined),
{ concurrency: "unbounded" },
)
return yield* withGlobalConfigDir(global, withInstanceDir(directory, effect))
@@ -284,7 +284,7 @@ async function check(map: (dir: string) => string) {
const cfg = await load(ctx)
expect(cfg.snapshot).toBe(true)
expect(ctx.directory).toBe(Filesystem.resolve(tmp.path))
expect(ctx.project.id).not.toBe(ProjectID.global)
expect(ctx.project.id).not.toBe(ProjectV2.ID.global)
},
})
} finally {
@@ -320,7 +320,7 @@ it.effect("creates global jsonc config with schema when no global configs exist"
Effect.gen(function* () {
yield* Config.use.get().pipe(provideInstanceEffect(dir))
const content = yield* AppFileSystem.use.readFileString(path.join(dir, "kilo.jsonc")) // kilocode_change
const content = yield* FSUtil.use.readFileString(path.join(dir, "kilo.jsonc")) // kilocode_change
expect(content).toContain('"$schema": "https://app.kilo.ai/config.json"') // kilocode_change
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
),
@@ -336,7 +336,7 @@ it.effect("does not create global config when KILO_CONFIG_DIR is set", () =>
Effect.gen(function* () {
yield* Config.use.get().pipe(provideInstanceEffect(dir))
expect(yield* AppFileSystem.use.existsSafe(path.join(dir, "opencode.jsonc"))).toBe(false)
expect(yield* FSUtil.use.existsSafe(path.join(dir, "opencode.jsonc"))).toBe(false)
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
),
)
@@ -401,9 +401,9 @@ it.instance("updates config and preserves empty shell sentinel", () =>
// kilocode_change - upstream hardcodes project config to config.json; Kilo writes to kilo.json
yield* writeConfigEffect(test.directory, { $schema: "https://opencode.ai/config.json", shell: "bash" })
yield* Config.Service.use((svc) => svc.update(ConfigParse.schema(Config.Info, { shell: "" }, "test:config")))
yield* Config.Service.use((svc) => svc.update(ConfigParse.schema(ConfigV1.Info, { shell: "" }, "test:config")))
const writtenConfig = yield* AppFileSystem.use.readJson(path.join(test.directory, "kilo.json")) // kilocode_change
const writtenConfig = yield* FSUtil.use.readJson(path.join(test.directory, "kilo.json")) // kilocode_change
expect(writtenConfig).toMatchObject({ shell: "" })
}),
)
@@ -413,7 +413,7 @@ it.effect("updates global config and omits empty shell key in json", () =>
Effect.gen(function* () {
yield* Config.use.updateGlobal({ shell: "" })
const writtenConfig = yield* AppFileSystem.use.readJson(path.join(dir, "kilo.json")) // kilocode_change
const writtenConfig = yield* FSUtil.use.readJson(path.join(dir, "kilo.json")) // kilocode_change
expect(writtenConfig).not.toHaveProperty("shell")
}),
),
@@ -425,8 +425,8 @@ it.effect("updates global config and omits empty shell key in jsonc", () =>
yield* Config.use.updateGlobal({ shell: "" })
const file = path.join(dir, "opencode.jsonc")
const writtenConfig = yield* AppFileSystem.use.readFileString(file)
const parsed = ConfigParse.schema(Config.Info, ConfigParse.jsonc(writtenConfig, file), file)
const writtenConfig = yield* FSUtil.use.readFileString(file)
const parsed = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(writtenConfig, file), file)
expect(writtenConfig).not.toContain('"shell"')
expect(parsed.shell).toBeUndefined()
expect(parsed.model).toBe("test/model")
@@ -489,7 +489,7 @@ it.instance("ignores legacy tui keys in opencode config", () =>
it.instance("loads JSONC config file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
// kilocode_change start
path.join(test.directory, "kilo.jsonc"),
`{
@@ -569,7 +569,7 @@ it.instance("rejects environment variable substitution in project config", () =>
it.instance("allows {file:} that stays inside the project root", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "in-project")
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "included.txt"), "in-project")
yield* writeConfigEffect(test.directory, {
$schema: "https://app.kilo.ai/config.json",
username: "{file:included.txt}",
@@ -595,7 +595,7 @@ it.instance("rejects {file:} that escapes the project root with parent directori
Effect.gen(function* () {
const test = yield* TestInstance
const outside = path.join(path.dirname(test.directory), "secret.txt")
yield* AppFileSystem.use.writeWithDirs(outside, "outside-secret")
yield* FSUtil.use.writeWithDirs(outside, "outside-secret")
yield* writeConfigEffect(test.directory, {
$schema: "https://app.kilo.ai/config.json",
username: "{file:../secret.txt}",
@@ -610,7 +610,7 @@ it.instance("rejects {file:} that escapes the project root through a symlink", (
const test = yield* TestInstance
const outside = path.join(path.dirname(test.directory), "secret.txt")
const link = path.join(test.directory, "secret-link")
yield* AppFileSystem.use.writeWithDirs(outside, "outside-secret")
yield* FSUtil.use.writeWithDirs(outside, "outside-secret")
yield* Effect.promise(() => fs.symlink(outside, link))
yield* writeConfigEffect(test.directory, {
$schema: "https://app.kilo.ai/config.json",
@@ -625,7 +625,7 @@ it.instance("blocks provider apiKey {file:} exfiltration that escapes the projec
Effect.gen(function* () {
const test = yield* TestInstance
const outside = path.join(path.dirname(test.directory), "creds.txt")
yield* AppFileSystem.use.writeWithDirs(outside, "leaked-credential")
yield* FSUtil.use.writeWithDirs(outside, "leaked-credential")
yield* writeConfigEffect(test.directory, {
$schema: "https://app.kilo.ai/config.json",
provider: {
@@ -644,7 +644,7 @@ it.instance("still allows global config to read absolute files", () =>
withGlobalConfig({}, ({ dir }) =>
Effect.gen(function* () {
const secret = path.join(dir, "secret.txt")
yield* AppFileSystem.use.writeWithDirs(secret, "global-secret")
yield* FSUtil.use.writeWithDirs(secret, "global-secret")
yield* writeConfigEffect(dir, {
$schema: "https://app.kilo.ai/config.json",
username: `{file:${secret}}`,
@@ -719,7 +719,7 @@ it.instance("validates config schema and reports warning on invalid fields", ()
it.instance("reports warning for invalid JSON", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "kilo.json"), "{ invalid json }")
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "kilo.json"), "{ invalid json }")
yield* Config.use.get()
const issues = yield* Config.Service.use((svc) => svc.warnings())
expect(issues.length).toBeGreaterThan(0)
@@ -837,7 +837,7 @@ it.instance("migrates mode field to agent field", () =>
it.instance("loads config from .kilo directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilo", "agent", "test.md"), // kilocode_change
`---
model: test/model
@@ -860,7 +860,7 @@ Test agent prompt`,
it.instance("agent markdown permission config preserves user key order", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilo", "agent", "ordered.md"), // kilocode_change
`---
permission:
@@ -880,7 +880,7 @@ Ordered permissions`,
it.instance("loads agents from .kilo/agents (plural)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilo", "agents", "helper.md"), // kilocode_change
`---
model: test/model
@@ -889,7 +889,7 @@ mode: subagent
Helper agent prompt`,
)
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilo", "agents", "nested", "child.md"), // kilocode_change
`---
model: test/model
@@ -921,7 +921,7 @@ Nested agent prompt`,
it.instance("loads commands from .kilo/command (singular)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilo", "command", "hello.md"), // kilocode_change
`---
description: Test command
@@ -929,7 +929,7 @@ description: Test command
Hello from singular command`,
)
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilo", "command", "nested", "child.md"), // kilocode_change
`---
description: Nested command
@@ -956,7 +956,7 @@ Nested command template`,
it.instance("loads commands from .kilo/commands (plural)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilo", "commands", "hello.md"), // kilocode_change
`---
description: Test command
@@ -964,7 +964,7 @@ description: Test command
Hello from plural commands`,
)
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilo", "commands", "nested", "child.md"), // kilocode_change
`---
description: Nested command
@@ -991,14 +991,14 @@ Nested command template`,
it.instance("prefers .kilo commands over legacy .kilocode commands", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilocode", "command", "hello.md"),
`---
description: Legacy command
---
Hello from legacy command`,
)
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilo", "command", "hello.md"),
`---
description: New command
@@ -1019,10 +1019,10 @@ it.instance("updates config and writes to file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Config.Service.use((svc) =>
svc.update(ConfigParse.schema(Config.Info, { model: "updated/model" }, "test:config")),
svc.update(ConfigParse.schema(ConfigV1.Info, { model: "updated/model" }, "test:config")),
)
const writtenConfig = yield* AppFileSystem.use.readJson(
const writtenConfig = yield* FSUtil.use.readJson(
path.join(test.directory, ".kilo", "kilo.jsonc"), // kilocode_change
)
expect(writtenConfig).toMatchObject({ model: "updated/model" })
@@ -1042,9 +1042,9 @@ it.effect("does not try to install dependencies in read-only KILO_CONFIG_DIR", (
const dir = yield* tmpdirScoped()
const readonly = path.join(dir, "readonly")
yield* AppFileSystem.use.ensureDir(readonly)
yield* AppFileSystem.use.chmod(readonly, 0o555)
yield* Effect.addFinalizer(() => AppFileSystem.use.chmod(readonly, 0o755).pipe(Effect.ignore))
yield* FSUtil.use.ensureDir(readonly)
yield* FSUtil.use.chmod(readonly, 0o555)
yield* Effect.addFinalizer(() => FSUtil.use.chmod(readonly, 0o755).pipe(Effect.ignore))
yield* withProcessEnv("KILO_CONFIG_DIR", readonly, Config.use.get().pipe(provideInstanceEffect(dir)))
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
@@ -1054,7 +1054,7 @@ it.effect("installs dependencies in writable KILO_CONFIG_DIR", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const configDir = path.join(dir, "configdir")
yield* AppFileSystem.use.ensureDir(configDir)
yield* FSUtil.use.ensureDir(configDir)
yield* withProcessEnv(
"KILO_CONFIG_DIR",
@@ -1064,7 +1064,7 @@ it.effect("installs dependencies in writable KILO_CONFIG_DIR", () =>
),
)
expect(yield* AppFileSystem.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json")
expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json")
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
)
@@ -1076,11 +1076,11 @@ it.instance("resolves scoped npm plugins in config", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const pluginDir = path.join(test.directory, "node_modules", "@scope", "plugin")
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, "package.json"),
JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
)
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
@@ -1093,7 +1093,7 @@ it.instance("resolves scoped npm plugins in config", () =>
2,
),
)
yield* AppFileSystem.use.writeWithDirs(path.join(pluginDir, "index.js"), "export default {}\n")
yield* FSUtil.use.writeWithDirs(path.join(pluginDir, "index.js"), "export default {}\n")
yield* writeConfigEffect(test.directory, { plugin: ["@scope/plugin"] })
const config = yield* Config.use.get()
@@ -1142,7 +1142,7 @@ it.effect("global config remains global when project config is disabled", () =>
it.instance("does not error when only custom agent is a subagent", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilo", "agent", "helper.md"), // kilocode_change
`---
model: test/model
@@ -1459,7 +1459,7 @@ it.instance("permission config preserves user key order", () =>
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
const config = ConfigParse.schema(
Config.Info,
ConfigV1.Info,
{
permission: {
bash: "allow",
@@ -1472,7 +1472,7 @@ test("config parser preserves permission order while rejecting unknown top-level
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
try {
ConfigParse.schema(Config.Info, { invalid_field: true }, "test")
ConfigParse.schema(ConfigV1.Info, { invalid_field: true }, "test")
throw new Error("expected config parse to fail")
} catch (err) {
const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
@@ -1727,16 +1727,19 @@ test("remote well-known config can use FetchHttpClient layer", async () => {
).pipe(
Effect.scoped,
Effect.provide(
Config.layer.pipe(
Layer.provide(Git.defaultLayer), // kilocode_change
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(wellKnownAuth(server.url.origin)),
Layer.provide(AccountTest.empty),
Layer.provideMerge(infra),
Layer.provide(NpmTest.noop),
Layer.provide(FetchHttpClient.layer),
Layer.mergeAll(
Config.layer.pipe(
Layer.provide(Git.defaultLayer), // kilocode_change
Layer.provide(testFlock),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(wellKnownAuth(server.url.origin)),
Layer.provide(AccountTest.empty),
Layer.provideMerge(infra),
Layer.provide(NpmTest.noop),
Layer.provide(FetchHttpClient.layer),
),
testInstanceStoreLayer,
),
),
Effect.runPromise,
@@ -1914,7 +1917,7 @@ describe("resolvePluginSpec", () => {
})
describe("deduplicatePluginOrigins", () => {
const dedupe = (plugins: ConfigPlugin.Spec[]) =>
const dedupe = (plugins: ConfigPluginV1.Spec[]) =>
ConfigPlugin.deduplicatePluginOrigins(
plugins.map((spec) => ({
spec,
@@ -1964,7 +1967,7 @@ describe("deduplicatePluginOrigins", () => {
{ global: { plugin: ["my-plugin@1.0.0"] } },
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilo", "plugin", "my-plugin.js"), // kilocode_change
"export default {}",
)
@@ -1999,7 +2002,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
"true",
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
yield* FSUtil.use.writeWithDirs(
path.join(test.directory, ".kilo", "command", "test-cmd.md"),
"# Test Command\nThis is a test command.",
)
@@ -2029,7 +2032,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
{ KILO_CONFIG_DIR: undefined, KILO_DISABLE_PROJECT_CONFIG: "true" },
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "CUSTOM.md"), "# Custom Instructions")
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "CUSTOM.md"), "# Custom Instructions")
// The relative instruction should be skipped without error
const config = yield* Config.use.get()
expect(config).toBeDefined()
@@ -2100,7 +2103,7 @@ describe("KILO_CONFIG_CONTENT token substitution", () => {
it.instance("substitutes {file:} tokens in KILO_CONFIG_CONTENT", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "api_key.txt"), "secret_key_from_file")
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "api_key.txt"), "secret_key_from_file")
yield* withProcessEnv(
"KILO_CONFIG_CONTENT",
JSON.stringify({
@@ -2120,7 +2123,7 @@ describe("KILO_CONFIG_CONTENT token substitution", () => {
test("parseManagedPlist strips MDM metadata keys", async () => {
const config = ConfigParse.schema(
Config.Info,
ConfigV1.Info,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
@@ -2148,7 +2151,7 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
test("parseManagedPlist parses server settings", async () => {
const config = ConfigParse.schema(
Config.Info,
ConfigV1.Info,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
@@ -2168,7 +2171,7 @@ test("parseManagedPlist parses server settings", async () => {
test("parseManagedPlist parses permission rules", async () => {
const config = ConfigParse.schema(
Config.Info,
ConfigV1.Info,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
@@ -2198,7 +2201,7 @@ test("parseManagedPlist parses permission rules", async () => {
test("parseManagedPlist parses enabled_providers", async () => {
const config = ConfigParse.schema(
Config.Info,
ConfigV1.Info,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
@@ -2215,7 +2218,7 @@ test("parseManagedPlist parses enabled_providers", async () => {
test("parseManagedPlist handles empty config", async () => {
const config = ConfigParse.schema(
Config.Info,
ConfigV1.Info,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
"test:mobileconfig",
+3 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ConfigLSP } from "../../src/config/lsp"
import { ConfigLSPV1 } from "@opencode-ai/core/v1/config/lsp"
// The LSP config refinement enforces: any custom (non-builtin) LSP server
// entry must declare an `extensions` array so the client knows which files
@@ -8,8 +8,8 @@ import { ConfigLSP } from "../../src/config/lsp"
// entries are exempt.
//
// `typescript` is a builtin server id (see src/lsp/server.ts).
describe("ConfigLSP.Info refinement", () => {
const decodeEffect = Schema.decodeUnknownSync(ConfigLSP.Info)
describe("ConfigLSPV1.Info refinement", () => {
const decodeEffect = Schema.decodeUnknownSync(ConfigLSPV1.Info)
describe("accepted inputs", () => {
test("true and false pass (top-level toggle)", () => {
+39 -39
View File
@@ -2,7 +2,7 @@ import { expect } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Config } from "@/config/config"
@@ -12,7 +12,7 @@ import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Config.defaultLayer, AppFileSystem.defaultLayer))
const it = testEffect(Layer.mergeAll(Config.defaultLayer, FSUtil.defaultLayer))
const winIt = process.platform === "win32" ? it.instance : it.instance.skip
const globalConfigFiles = ["kilo.json", "kilo.jsonc", "tui.json", "tui.jsonc"].map((file) =>
@@ -20,7 +20,7 @@ const globalConfigFiles = ["kilo.json", "kilo.jsonc", "tui.json", "tui.jsonc"].m
)
const cleanState = Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
delete process.env.KILO_CONFIG
delete process.env.KILO_TUI_CONFIG
yield* Effect.forEach(globalConfigFiles, (file) => fs.remove(file, { force: true }).pipe(Effect.ignore), {
@@ -85,7 +85,7 @@ const getTuiConfig = (directory: string) =>
it.instance("keeps server and tui plugin merge semantics aligned", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const local = path.join(test.directory, ".kilo") // kilocode_change
yield* fs.makeDirectory(local, { recursive: true })
@@ -124,7 +124,7 @@ it.instance("keeps server and tui plugin merge semantics aligned", () =>
it.instance("loads tui config with the same precedence order as server config paths", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { theme: "global" })
yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "project" })
@@ -143,7 +143,7 @@ it.instance("loads tui config with the same precedence order as server config pa
it.instance("resolves attention config defaults and overrides", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
expect((yield* getTuiConfig(test.directory)).attention).toEqual({
@@ -191,7 +191,7 @@ it.instance("resolves attention config defaults and overrides", () =>
it.instance("migrates tui-specific keys from kilo.json when tui.json does not exist", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const source = path.join(test.directory, "kilo.json")
yield* fs.writeJson(source, {
@@ -221,7 +221,7 @@ it.instance("migrates tui-specific keys from kilo.json when tui.json does not ex
it.instance("migrates project legacy tui keys even when global tui.json already exists", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { theme: "global" })
yield* fs.writeJson(path.join(test.directory, "kilo.json"), {
@@ -244,7 +244,7 @@ it.instance("migrates project legacy tui keys even when global tui.json already
it.instance("drops unknown legacy tui keys during migration", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "kilo.json"), {
theme: "migrated-theme",
@@ -265,7 +265,7 @@ it.instance("drops unknown legacy tui keys during migration", () =>
it.instance("skips migration when kilo.jsonc is syntactically invalid", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeFileString(
path.join(test.directory, "kilo.jsonc"),
@@ -291,7 +291,7 @@ it.instance("skips migration when kilo.jsonc is syntactically invalid", () =>
it.instance("skips migration when tui.json already exists", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "kilo.json"), { theme: "legacy" })
yield* fs.writeJson(path.join(test.directory, "tui.json"), { diff_style: "stacked" })
@@ -310,7 +310,7 @@ it.instance("skips migration when tui.json already exists", () =>
it.instance("continues loading tui config when legacy source cannot be stripped", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const source = path.join(test.directory, "kilo.json")
yield* fs.writeJson(source, { theme: "readonly-theme" })
@@ -335,7 +335,7 @@ it.instance("continues loading tui config when legacy source cannot be stripped"
it.instance("migration backup preserves JSONC comments", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeFileString(
path.join(test.directory, "kilo.jsonc"),
@@ -362,7 +362,7 @@ it.instance("migration backup preserves JSONC comments", () =>
it.instance("migrates legacy tui keys across multiple kilo.json levels", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const nested = path.join(test.directory, "apps", "client")
yield* fs.makeDirectory(nested, { recursive: true })
@@ -380,7 +380,7 @@ it.instance("migrates legacy tui keys across multiple kilo.json levels", () =>
it.instance("flattens nested tui key inside tui.json", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
theme: "outer",
@@ -398,7 +398,7 @@ it.instance("flattens nested tui key inside tui.json", () =>
it.instance("top-level keys in tui.json take precedence over nested tui key", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
diff_style: "auto",
@@ -415,7 +415,7 @@ it.instance("top-level keys in tui.json take precedence over nested tui key", ()
it.instance("project config takes precedence over KILO_TUI_CONFIG (matches KILO_CONFIG)", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const custom = path.join(test.directory, "custom-tui.json")
yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "project", diff_style: "auto" })
@@ -437,7 +437,7 @@ it.instance("project config takes precedence over KILO_TUI_CONFIG (matches KILO_
it.instance("merges keybind overrides across precedence layers", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { keybinds: { app_exit: "ctrl+q" } })
yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { theme_list: "ctrl+k" } })
@@ -452,7 +452,7 @@ it.instance("merges keybind overrides across precedence layers", () =>
it.instance("ignores unknown keybind names without dropping valid overrides from the same file", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
keybinds: {
@@ -471,7 +471,7 @@ it.instance("ignores unknown keybind names without dropping valid overrides from
it.instance("resolves keybind lookup from canonical keybinds", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
keybinds: {
@@ -516,7 +516,7 @@ it.instance("resolves keybind lookup from canonical keybinds", () =>
it.instance("keybinds accept OpenTUI binding specs", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
keybinds: {
@@ -556,7 +556,7 @@ winIt("defaults Ctrl+Z to input undo on Windows", () =>
winIt("keeps explicit input undo overrides on Windows", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { input_undo: "ctrl+y" } })
@@ -570,7 +570,7 @@ winIt("keeps explicit input undo overrides on Windows", () =>
winIt("ignores terminal suspend bindings on Windows", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { terminal_suspend: "alt+z" } })
@@ -600,7 +600,7 @@ it.instance("ignores explicit keybind terminal suspend binding on Windows", () =
withPlatform(
"win32",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
keybinds: {
@@ -620,7 +620,7 @@ it.instance("keeps explicit configured keybind input undo on Windows", () =>
withPlatform(
"win32",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
keybinds: {
@@ -638,7 +638,7 @@ it.instance("keeps explicit configured keybind input undo on Windows", () =>
it.instance("KILO_TUI_CONFIG provides settings when no project config exists", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const custom = path.join(test.directory, "custom-tui.json")
yield* fs.writeJson(custom, { theme: "from-env", diff_style: "stacked" })
@@ -659,7 +659,7 @@ it.instance("KILO_TUI_CONFIG provides settings when no project config exists", (
it.instance("does not derive tui path from KILO_CONFIG", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const customDir = path.join(test.directory, "custom")
yield* fs.makeDirectory(customDir, { recursive: true })
@@ -685,7 +685,7 @@ it.instance("applies env and file substitutions in global tui.json", () =>
"TUI_THEME_TEST",
"env-theme",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
// Global config is trusted, so {env:}/{file:} references resolve.
yield* fs.writeFileString(path.join(Global.Path.config, "keybind.txt"), "ctrl+q")
@@ -708,7 +708,7 @@ it.instance("does not substitute env references in untrusted project tui.json",
"TUI_THEME_TEST",
"env-theme",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
theme: "{env:TUI_THEME_TEST}",
@@ -725,7 +725,7 @@ it.instance("does not substitute env references in untrusted project tui.json",
it.instance("applies in-project file references in project tui.json", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
// {file:} that stays inside the project root is allowed even in untrusted project config.
yield* fs.writeFileString(path.join(test.directory, "keybind.txt"), "ctrl+q")
@@ -742,7 +742,7 @@ it.instance("applies in-project file references in project tui.json", () =>
it.instance("rejects project tui.json file references that escape the project root", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const outside = path.join(path.dirname(test.directory), "keybind.txt")
yield* fs.writeFileString(outside, "ctrl+q")
@@ -760,7 +760,7 @@ it.instance("rejects project tui.json file references that escape the project ro
it.instance("applies file substitutions when first identical token is in a commented line", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
// kilocode_change start - global config is trusted, so the second (uncommented) reference resolves
yield* fs.writeFileString(path.join(Global.Path.config, "theme.txt"), "resolved-theme")
@@ -782,7 +782,7 @@ it.instance("applies file substitutions when first identical token is in a comme
it.instance("loads .kilo/tui.json", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeWithDirs(
path.join(test.directory, ".kilo", "tui.json"),
@@ -798,7 +798,7 @@ it.instance("loads .kilo/tui.json", () =>
it.instance("supports tuple plugin specs with options in tui.json", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]],
@@ -820,7 +820,7 @@ it.instance("supports tuple plugin specs with options in tui.json", () =>
it.instance("deduplicates tuple plugin specs by name with higher precedence winning", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
plugin: [["acme-plugin@1.0.0", { source: "global" }]],
@@ -856,7 +856,7 @@ it.instance("deduplicates tuple plugin specs by name with higher precedence winn
it.instance("tracks global and local plugin metadata in merged tui config", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { plugin: ["global-plugin@1.0.0"] })
yield* fs.writeJson(path.join(test.directory, "tui.json"), { plugin: ["local-plugin@2.0.0"] })
@@ -882,7 +882,7 @@ it.instance("tracks global and local plugin metadata in merged tui config", () =
it.instance("merges plugin_enabled flags across config layers", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
plugin_enabled: {
@@ -910,7 +910,7 @@ it.instance("merges plugin_enabled flags across config layers", () =>
it.instance("silently skips malformed tui.json - load failures degrade to {}", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeFileString(path.join(test.directory, "tui.json"), '{ "theme": "broken",')
yield* fs.writeWithDirs(path.join(test.directory, ".kilo", "tui.json"), JSON.stringify({ theme: "fallback" })) // kilocode_change
@@ -924,7 +924,7 @@ it.instance("silently skips malformed tui.json - load failures degrade to {}", (
it.instance("silently skips non-ENOENT read failures (e.g. tui.json is a directory) - fallback layer still loads", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.makeDirectory(path.join(test.directory, "tui.json"), { recursive: true })
yield* fs.writeWithDirs(path.join(test.directory, ".kilo", "tui.json"), JSON.stringify({ theme: "fallback" })) // kilocode_change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { getAdapter, registerAdapter } from "../../src/control-plane/adapters"
import { ProjectID } from "../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import type { WorkspaceInfo } from "../../src/control-plane/types"
function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInfo {
@@ -36,8 +36,8 @@ function adapter(dir: string) {
describe("control-plane/adapters", () => {
test("isolates custom adapters by project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
const one = ProjectV2.ID.make(`project-${Math.random().toString(36).slice(2)}`)
const two = ProjectV2.ID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdapter(one, type, adapter("/one"))
registerAdapter(two, type, adapter("/two"))
@@ -53,7 +53,7 @@ describe("control-plane/adapters", () => {
test("latest install wins within a project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
const id = ProjectV2.ID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdapter(id, type, adapter("/one"))
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({
@@ -7,23 +7,23 @@ import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Exit, Fiber, Layer, Schema } from "effect"
import { FetchHttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { eq } from "drizzle-orm"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import * as Log from "@opencode-ai/core/util/log"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Database } from "@/storage/db"
import { ProjectID } from "@/project/schema"
import { ProjectTable } from "@/project/project.sql"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session as SessionNs } from "@/session/session"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import { SyncEvent } from "@/sync"
import { EventSequenceTable } from "@/sync/event.sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { EventSequenceTable } from "@opencode-ai/core/event/sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideTmpdirInstance, requireInstance, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
import * as Workspace from "../../src/control-plane/workspace"
import { InstanceStore } from "@/project/instance-store"
@@ -33,6 +33,7 @@ import { SessionPrompt } from "@/session/prompt"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
void Log.init({ print: false })
@@ -48,12 +49,13 @@ const workspaceLayer = (experimentalWorkspaces: boolean) =>
Workspace.layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))),
)
@@ -62,6 +64,7 @@ const testServerLayer = Layer.mergeAll(
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
workspaceLayer(true),
SessionNs.defaultLayer,
Database.defaultLayer,
)
const it = testEffect(testServerLayer)
@@ -105,7 +108,6 @@ function restoreEnv() {
}
beforeEach(() => {
Database.close()
restoreEnv()
process.env.KILO_EXPERIMENTAL_WORKSPACES = "true"
})
@@ -129,7 +131,7 @@ async function initGitRepo(dir: string) {
await $`git commit -m "base"`.cwd(dir).quiet()
}
const startWorkspaceSyncingWithFlag = (projectID: ProjectID, experimentalWorkspaces: boolean) =>
const startWorkspaceSyncingWithFlag = (projectID: ProjectV2.ID, experimentalWorkspaces: boolean) =>
Effect.runPromise(
Workspace.use.startWorkspaceSyncing(projectID).pipe(Effect.provide(workspaceLayer(experimentalWorkspaces))),
)
@@ -265,9 +267,9 @@ function serverUrl() {
})
}
function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<Workspace.Info>): Workspace.Info {
function workspaceInfo(projectID: ProjectV2.ID, type: string, input?: Partial<Workspace.Info>): Workspace.Info {
return {
id: input?.id ?? WorkspaceID.ascending(),
id: input?.id ?? WorkspaceV2.ID.ascending(),
type,
name: input?.name ?? unique("workspace"),
branch: input?.branch ?? null,
@@ -279,7 +281,7 @@ function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<Works
}
function insertWorkspace(info: Workspace.Info) {
Database.use((db) =>
return Database.Service.use(({ db }) =>
db
.insert(WorkspaceTable)
.values({
@@ -292,55 +294,66 @@ function insertWorkspace(info: Workspace.Info) {
project_id: info.projectID,
time_used: info.timeUsed,
})
.run(),
.run()
.pipe(Effect.orDie),
)
}
function insertProject(id: ProjectID, worktree: string) {
Database.use((db) =>
function insertProject(id: ProjectV2.ID, worktree: string) {
return Database.Service.use(({ db }) =>
db
.insert(ProjectTable)
.values({
id,
worktree,
worktree: AbsolutePath.make(worktree),
vcs: null,
name: null,
time_created: Date.now(),
time_updated: Date.now(),
sandboxes: [],
})
.run(),
.run()
.pipe(Effect.orDie),
)
}
function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceID) {
Database.use((db) =>
db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run(),
function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceV2.ID) {
return Database.Service.use(({ db }) =>
db
.update(SessionTable)
.set({ workspace_id: workspaceID })
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie),
)
}
function sessionSequence(sessionID: SessionID) {
return Database.use((db) =>
return Database.Service.use(({ db }) =>
db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.get(),
)?.seq
.get()
.pipe(
Effect.orDie,
Effect.map((row) => row?.seq),
),
)
}
function sessionSequenceOwner(sessionID: SessionID) {
return Database.use((db) =>
return Database.Service.use(({ db }) =>
db
.select({ ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.get(),
)?.ownerID
}
function sessionUpdatedType() {
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
.get()
.pipe(
Effect.orDie,
Effect.map((row) => row?.ownerID),
),
)
}
describe("workspace schemas and exports", () => {
@@ -352,10 +365,10 @@ describe("workspace schemas and exports", () => {
test("validates create input with workspace id, project id, branch, type, and extra", () => {
const input = {
id: WorkspaceID.ascending("wrk_schema_create"),
id: WorkspaceV2.ID.ascending("wrk_schema_create"),
type: "worktree",
branch: "feature/schema",
projectID: ProjectID.make("project-schema"),
projectID: ProjectV2.ID.make("project-schema"),
extra: { nested: true },
}
@@ -372,7 +385,7 @@ describe("workspace CRUD", () => {
() =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
expect(yield* workspace.get(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined()
expect(yield* workspace.get(WorkspaceV2.ID.ascending("wrk_missing_get"))).toBeUndefined()
}),
{ git: true },
)
@@ -383,24 +396,24 @@ describe("workspace CRUD", () => {
Effect.gen(function* () {
const instance = yield* requireInstance
const workspace = yield* Workspace.Service
const otherProjectID = ProjectID.make("project-other")
insertProject(otherProjectID, "/tmp/other")
const otherProjectID = ProjectV2.ID.make("project-other")
yield* insertProject(otherProjectID, "/tmp/other")
const a = workspaceInfo(instance.project.id, "manual", {
id: WorkspaceID.ascending("wrk_a_list"),
id: WorkspaceV2.ID.ascending("wrk_a_list"),
branch: "a",
directory: "/a",
extra: { a: true },
})
const b = workspaceInfo(instance.project.id, "manual", {
id: WorkspaceID.ascending("wrk_b_list"),
id: WorkspaceV2.ID.ascending("wrk_b_list"),
branch: "b",
directory: "/b",
extra: ["b"],
})
const other = workspaceInfo(otherProjectID, "manual", { id: WorkspaceID.ascending("wrk_c_list") })
insertWorkspace(b)
insertWorkspace(other)
insertWorkspace(a)
const other = workspaceInfo(otherProjectID, "manual", { id: WorkspaceV2.ID.ascending("wrk_c_list") })
yield* insertWorkspace(b)
yield* insertWorkspace(other)
yield* insertWorkspace(a)
expect(yield* workspace.list(instance.project)).toEqual([a, b])
}),
@@ -418,7 +431,7 @@ describe("workspace CRUD", () => {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://otel.test"
process.env.OTEL_RESOURCE_ATTRIBUTES = "service.name=opencode-test"
const workspaceID = WorkspaceID.ascending("wrk_create_local")
const workspaceID = WorkspaceV2.ID.ascending("wrk_create_local")
const type = unique("create-local")
const targetDir = path.join(instance.directory, "created-local")
const recorded = recordedAdapter({
@@ -578,11 +591,11 @@ describe("workspace CRUD", () => {
const workspace = yield* Workspace.Service
const type = unique("list-sync")
const existing = workspaceInfo(instance.project.id, type, {
id: WorkspaceID.ascending("wrk_list_sync_existing"),
id: WorkspaceV2.ID.ascending("wrk_list_sync_existing"),
name: "existing",
directory: path.join(instance.directory, "existing"),
})
insertWorkspace(existing)
yield* insertWorkspace(existing)
const discovered = {
type,
@@ -748,7 +761,7 @@ describe("workspace CRUD", () => {
() =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
expect(yield* workspace.remove(WorkspaceID.ascending("wrk_missing_remove"))).toBeUndefined()
expect(yield* workspace.remove(WorkspaceV2.ID.ascending("wrk_missing_remove"))).toBeUndefined()
}),
{ git: true },
)
@@ -767,8 +780,8 @@ describe("workspace CRUD", () => {
const info = yield* workspace.create({ type, branch: null, projectID: instance.project.id, extra: null })
const one = yield* sessionSvc.create({})
const two = yield* sessionSvc.create({})
attachSessionToWorkspace(one.id, info.id)
attachSessionToWorkspace(two.id, info.id)
yield* attachSessionToWorkspace(one.id, info.id)
yield* attachSessionToWorkspace(two.id, info.id)
const removed = yield* workspace.remove(info.id)
@@ -776,10 +789,14 @@ describe("workspace CRUD", () => {
expect(yield* workspace.get(info.id)).toBeUndefined()
expect(recorded.calls.remove).toEqual([info])
expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
const { db } = yield* Database.Service
expect(
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(),
),
yield* db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, info.id))
.all()
.pipe(Effect.orDie),
).toEqual([])
})
},
@@ -793,7 +810,7 @@ describe("workspace CRUD", () => {
const instance = yield* requireInstance
const workspace = yield* Workspace.Service
const type = unique("remove-throws")
const info = workspaceInfo(instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") })
const info = workspaceInfo(instance.project.id, type, { id: WorkspaceV2.ID.ascending("wrk_remove_throws") })
registerAdapter(
instance.project.id,
type,
@@ -806,7 +823,7 @@ describe("workspace CRUD", () => {
},
}).adapter,
)
insertWorkspace(info)
yield* insertWorkspace(info)
expect(yield* workspace.remove(info.id)).toEqual(info)
expect(yield* workspace.get(info.id)).toBeUndefined()
@@ -826,25 +843,25 @@ describe("workspace CRUD", () => {
const targetType = unique("warp-target-local")
const previous = workspaceInfo(instance.project.id, previousType)
const target = workspaceInfo(instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
yield* insertWorkspace(previous)
yield* insertWorkspace(target)
registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
registerAdapter(instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
yield* attachSessionToWorkspace(session.id, previous.id)
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
const { db } = yield* Database.Service
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
(yield* db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get()
.pipe(Effect.orDie))?.workspaceID,
).toBe(target.id)
expect(sessionSequenceOwner(session.id)).toBe(target.id)
expect(yield* sessionSequenceOwner(session.id)).toBe(target.id)
})
},
{ git: true },
@@ -869,12 +886,12 @@ describe("workspace CRUD", () => {
const previous = workspaceInfo(instance.project.id, previousType)
const target = workspaceInfo(instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
yield* insertWorkspace(previous)
yield* insertWorkspace(target)
registerAdapter(instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
registerAdapter(instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
yield* attachSessionToWorkspace(session.id, previous.id)
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
@@ -895,23 +912,23 @@ describe("workspace CRUD", () => {
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-detach-local")
const previous = workspaceInfo(instance.project.id, previousType)
insertWorkspace(previous)
yield* insertWorkspace(previous)
registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
yield* attachSessionToWorkspace(session.id, previous.id)
yield* workspace.sessionWarp({ workspaceID: null, sessionID: session.id })
const { db } = yield* Database.Service
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
(yield* db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get()
.pipe(Effect.orDie))?.workspaceID,
).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(instance.project.id)
expect(yield* sessionSequenceOwner(session.id)).toBe(instance.project.id)
})
},
{ git: true },
@@ -928,9 +945,9 @@ describe("workspace CRUD", () => {
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-detach-workspace-instance")
const previous = workspaceInfo(projectID, previousType)
insertWorkspace(previous)
yield* insertWorkspace(previous)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
yield* attachSessionToWorkspace(session.id, previous.id)
const workspaceProjectID = yield* provideTmpdirInstance(
(workspaceDir) =>
@@ -944,17 +961,17 @@ describe("workspace CRUD", () => {
{ git: true },
)
const { db } = yield* Database.Service
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
(yield* db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get()
.pipe(Effect.orDie))?.workspaceID,
).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(projectID)
expect(sessionSequenceOwner(session.id)).not.toBe(workspaceProjectID)
expect(yield* sessionSequenceOwner(session.id)).toBe(projectID)
expect(yield* sessionSequenceOwner(session.id)).not.toBe(workspaceProjectID)
}),
{ git: true },
)
@@ -962,6 +979,7 @@ describe("workspace CRUD", () => {
it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => {
const calls: FetchCall[] = []
let historySessionID: SessionID | undefined
let historySession: SessionNs.Info | undefined
let historyNextSeq = 0
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
@@ -982,8 +1000,8 @@ describe("workspace CRUD", () => {
id: `evt_${unique("warp-source-history")}`,
aggregate_id: historySessionID!,
seq: historyNextSeq,
type: sessionUpdatedType(),
data: { sessionID: historySessionID!, info: { title: "from source history" } },
type: "session.updated.1",
data: { sessionID: historySessionID!, info: historySession! },
},
])
}
@@ -1007,14 +1025,15 @@ describe("workspace CRUD", () => {
const targetType = unique("warp-remote-target")
const previous = workspaceInfo(instance.project.id, previousType)
const target = workspaceInfo(instance.project.id, targetType, { directory: "remote-target-dir" })
insertWorkspace(previous)
insertWorkspace(target)
yield* insertWorkspace(previous)
yield* insertWorkspace(target)
registerAdapter(instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter)
registerAdapter(instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
yield* attachSessionToWorkspace(session.id, previous.id)
historySessionID = session.id
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
historySession = { ...session, workspaceID: previous.id, title: "from source history" }
historyNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
@@ -1033,18 +1052,18 @@ describe("workspace CRUD", () => {
{
aggregateID: session.id,
seq: 0,
type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version),
type: "session.created.1",
},
{
aggregateID: session.id,
seq: historyNextSeq,
type: sessionUpdatedType(),
type: "session.updated.1",
},
],
})
expect(calls[4].json).toEqual({ sessionID: session.id })
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
expect(sessionSequenceOwner(session.id)).toBe(target.id)
expect(yield* sessionSequenceOwner(session.id)).toBe(target.id)
}),
{ git: true },
)
@@ -1064,8 +1083,8 @@ describe("workspace sync state", () => {
const type = unique("flag-disabled")
const info = workspaceInfo(instance.project.id, type)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, info.id)
insertWorkspace(info)
yield* attachSessionToWorkspace(session.id, info.id)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter)
yield* Effect.promise(() => startWorkspaceSyncingWithFlag(instance.project.id, false))
@@ -1090,12 +1109,10 @@ describe("workspace sync state", () => {
const second = workspaceInfo(projectID, secondType)
yield* Effect.promise(() => fs.mkdir(path.join(dir, "first"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(path.join(dir, "second"), { recursive: true }))
yield* Effect.sync(() => {
insertWorkspace(first)
insertWorkspace(second)
registerAdapter(projectID, firstType, localAdapter(path.join(dir, "first")).adapter)
registerAdapter(projectID, secondType, localAdapter(path.join(dir, "second")).adapter)
})
yield* insertWorkspace(first)
yield* insertWorkspace(second)
registerAdapter(projectID, firstType, localAdapter(path.join(dir, "first")).adapter)
registerAdapter(projectID, secondType, localAdapter(path.join(dir, "second")).adapter)
yield* Effect.addFinalizer(() =>
Effect.all([workspace.remove(first.id), workspace.remove(second.id)], { discard: true }).pipe(Effect.ignore),
)
@@ -1123,13 +1140,13 @@ describe("workspace sync state", () => {
const sessionSvc = yield* SessionNs.Service
const type = unique("missing-local")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(
instance.project.id,
type,
localAdapter(path.join(dir, "missing-target"), { createDir: false }).adapter,
)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1159,9 +1176,9 @@ describe("workspace sync state", () => {
const info = workspaceInfo(instance.project.id, type)
const target = path.join(dir, "dedupe-local")
yield* Effect.promise(() => fs.mkdir(target, { recursive: true }))
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, localAdapter(target).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1213,9 +1230,9 @@ describe("workspace sync state", () => {
try {
const type = unique("remote-start")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sync`).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
yield* eventuallyEffect(
@@ -1267,9 +1284,9 @@ describe("workspace sync state", () => {
const instance = yield* requireInstance
const type = unique("remote-connect-fail")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/failed`).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1308,9 +1325,9 @@ describe("workspace sync state", () => {
const instance = yield* requireInstance
const type = unique("remote-history-fail")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1330,6 +1347,7 @@ describe("workspace sync state", () => {
it.live("sync history sends the local sequence fence and replays returned events in workspace context", () => {
const historyBodies: unknown[] = []
let historySessionID: SessionID | undefined
let historySession: SessionNs.Info | undefined
let historyNextSeq = 0
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
@@ -1346,8 +1364,8 @@ describe("workspace sync state", () => {
id: `evt_${unique("history")}`,
aggregate_id: historySessionID!,
seq: historyNextSeq,
type: sessionUpdatedType(),
data: { sessionID: historySessionID!, info: { title: "from history" } },
type: "session.updated.1",
data: { sessionID: historySessionID!, info: historySession! },
},
]),
)
@@ -1366,12 +1384,13 @@ describe("workspace sync state", () => {
try {
const type = unique("history-replay")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/history`).adapter)
const session = yield* sessionSvc.create({ title: "before history" })
attachSessionToWorkspace(session.id, info.id)
yield* attachSessionToWorkspace(session.id, info.id)
historySessionID = session.id
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
historySession = { ...session, workspaceID: info.id, title: "from history" }
historyNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1385,8 +1404,9 @@ describe("workspace sync state", () => {
captured.events.some(
(event) =>
event.workspace === info.id &&
event.payload.type === "sync" &&
event.payload.syncEvent.seq === historyNextSeq,
event.payload.type === "session.updated" &&
event.payload.properties.sessionID === session.id &&
event.payload.properties.info.title === "from history",
),
).toBe(true)
yield* workspace.remove(info.id)
@@ -1434,9 +1454,9 @@ describe("workspace sync state", () => {
try {
const type = unique("sse-forward")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1473,6 +1493,7 @@ describe("workspace sync state", () => {
it.live("SSE sync events are replayed and forwarded", () => {
let sseSessionID: SessionID | undefined
let sseSession: SessionNs.Info | undefined
let sseNextSeq = 0
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
@@ -1492,8 +1513,8 @@ describe("workspace sync state", () => {
id: `evt_${unique("sse")}`,
aggregateID: sseSessionID!,
seq: sseNextSeq,
type: sessionUpdatedType(),
data: { sessionID: sseSessionID!, info: { title: "from sse" } },
type: "session.updated.1",
data: { sessionID: sseSessionID!, info: sseSession! },
},
},
},
@@ -1516,12 +1537,13 @@ describe("workspace sync state", () => {
try {
const type = unique("sse-sync")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
yield* insertWorkspace(info)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter)
const session = yield* sessionSvc.create({ title: "before sse" })
attachSessionToWorkspace(session.id, info.id)
yield* attachSessionToWorkspace(session.id, info.id)
sseSessionID = session.id
sseNextSeq = (sessionSequence(session.id) ?? -1) + 1
sseSession = { ...session, workspaceID: info.id, title: "from sse" }
sseNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1
yield* workspace.startWorkspaceSyncing(instance.project.id)
@@ -1555,7 +1577,7 @@ describe("workspace waitForSync", () => {
() =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
expect(yield* workspace.waitForSync(WorkspaceID.ascending("wrk_wait_empty"), {})).toBeUndefined()
expect(yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_empty"), {})).toBeUndefined()
}),
{ git: true },
)
@@ -1566,11 +1588,14 @@ describe("workspace waitForSync", () => {
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const sessionID = SessionID.descending("ses_wait_done")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run())
const { db } = yield* Database.Service
yield* db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run().pipe(Effect.orDie)
expect(yield* workspace.waitForSync(WorkspaceID.ascending("wrk_wait_done"), { [sessionID]: 4 })).toBeUndefined()
expect(
yield* workspace.waitForSync(WorkspaceID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }),
yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done"), { [sessionID]: 4 }),
).toBeUndefined()
expect(
yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }),
).toBeUndefined()
}),
{ git: true },
@@ -1581,22 +1606,22 @@ describe("workspace waitForSync", () => {
() =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = WorkspaceID.ascending("wrk_wait_event")
const workspaceID = WorkspaceV2.ID.ascending("wrk_wait_event")
const sessionID = SessionID.descending("ses_wait_event")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 1 }).run())
const { db } = yield* Database.Service
yield* db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 1 }).run().pipe(Effect.orDie)
yield* Effect.all(
[
workspace.waitForSync(workspaceID, { [sessionID]: 2 }),
Effect.gen(function* () {
yield* Effect.sleep("10 millis")
Database.use((db) =>
db
.update(EventSequenceTable)
.set({ seq: 2 })
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.run(),
)
yield* db
.update(EventSequenceTable)
.set({ seq: 2 })
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.run()
.pipe(Effect.orDie)
GlobalBus.emit("event", { workspace: workspaceID, payload: { type: "anything" } })
}),
],
@@ -1611,24 +1636,24 @@ describe("workspace waitForSync", () => {
() =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = WorkspaceID.ascending("wrk_wait_sync_any")
const workspaceID = WorkspaceV2.ID.ascending("wrk_wait_sync_any")
const sessionID = SessionID.descending("ses_wait_sync_any")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 0 }).run())
const { db } = yield* Database.Service
yield* db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 0 }).run().pipe(Effect.orDie)
yield* Effect.all(
[
workspace.waitForSync(workspaceID, { [sessionID]: 1 }),
Effect.gen(function* () {
yield* Effect.sleep("10 millis")
Database.use((db) =>
db
.update(EventSequenceTable)
.set({ seq: 1 })
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.run(),
)
yield* db
.update(EventSequenceTable)
.set({ seq: 1 })
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.run()
.pipe(Effect.orDie)
GlobalBus.emit("event", {
workspace: WorkspaceID.ascending("wrk_other_workspace"),
workspace: WorkspaceV2.ID.ascending("wrk_other_workspace"),
payload: { type: "sync" },
})
}),
@@ -1648,7 +1673,7 @@ describe("workspace waitForSync", () => {
const reason = new Error("caller aborted")
const fiber = yield* Effect.forkChild(
workspace.waitForSync(
WorkspaceID.ascending("wrk_wait_abort"),
WorkspaceV2.ID.ascending("wrk_wait_abort"),
{ [SessionID.descending("ses_wait_abort")]: 1 },
abort.signal,
),
@@ -1668,7 +1693,7 @@ describe("workspace waitForSync", () => {
const sessionID = SessionID.descending("ses_wait_timeout")
expectExitContains(
yield* Effect.exit(
workspace.waitForSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }, undefined, 25),
workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }, undefined, 25),
),
`Timed out waiting for sync fence: {"${sessionID}":1}`,
)
@@ -2,7 +2,7 @@ import { expect } from "bun:test"
import { Effect, Layer, Context } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service"
import { ProjectID } from "../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { it } from "../lib/effect"
class Shared extends Context.Service<Shared, { readonly id: number }>()("@test/Shared") {}
@@ -79,7 +79,7 @@ it.live("makeRuntime inherits InstanceRef from the current fiber", () =>
directory: testDirectory,
worktree: testDirectory,
project: {
id: ProjectID.global,
id: ProjectV2.ID.global,
worktree: testDirectory,
time: { created: 0, updated: 0 },
sandboxes: [],
@@ -24,12 +24,10 @@ describe("RuntimeFlags", () => {
fromConfig({
KILO_PURE: "true",
KILO_DISABLE_DEFAULT_PLUGINS: "true",
KILO_DISABLE_CHANNEL_DB: "true",
KILO_AUTO_SHARE: "true",
KILO_DISABLE_EMBEDDED_WEB_UI: "true",
KILO_DISABLE_EXTERNAL_SKILLS: "true",
KILO_DISABLE_LSP_DOWNLOAD: "true",
KILO_SKIP_MIGRATIONS: "true",
KILO_EXPERIMENTAL: "true",
KILO_ENABLE_EXA: "true",
KILO_ENABLE_PARALLEL: "true",
@@ -43,17 +41,15 @@ describe("RuntimeFlags", () => {
expect(flags.pure).toBe(true)
expect(flags.autoShare).toBe(true)
expect(flags.disableDefaultPlugins).toBe(true)
expect(flags.disableChannelDb).toBe(true)
expect(flags.disableEmbeddedWebUi).toBe(true)
expect(flags.disableExternalSkills).toBe(true)
expect(flags.disableLspDownload).toBe(true)
expect(flags.skipMigrations).toBe(true)
expect(flags.disableClaudeCodePrompt).toBe(false)
expect(flags.enableExa).toBe(true)
expect(flags.enableParallel).toBe(true)
expect(flags.enableExperimentalModels).toBe(true)
expect(flags.enableQuestionTool).toBe(true)
expect(flags.experimentalScout).toBe(true)
expect(flags.experimentalReferences).toBe(true)
expect(flags.experimentalBackgroundSubagents).toBe(true)
expect(flags.experimentalLspTy).toBe(false)
expect(flags.experimentalLspTool).toBe(true)
@@ -111,11 +107,9 @@ describe("RuntimeFlags", () => {
expect(flags.pure).toBe(false)
expect(flags.autoShare).toBe(false)
expect(flags.disableDefaultPlugins).toBe(true)
expect(flags.disableChannelDb).toBe(false)
expect(flags.disableEmbeddedWebUi).toBe(false)
expect(flags.disableExternalSkills).toBe(false)
expect(flags.disableLspDownload).toBe(false)
expect(flags.skipMigrations).toBe(false)
expect(flags.disableClaudeCodePrompt).toBe(false)
expect(flags.disableClaudeCodeSkills).toBe(false)
expect(flags.enableExa).toBe(false)
@@ -168,22 +162,6 @@ describe("RuntimeFlags", () => {
}),
)
it.effect("skipMigrations defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.skipMigrations).toBe(false)
}),
)
it.effect("skipMigrations reads KILO_SKIP_MIGRATIONS", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ KILO_SKIP_MIGRATIONS: "true" })))
expect(flags.skipMigrations).toBe(true)
}),
)
it.effect("disableClaudeCodePrompt defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
@@ -344,7 +322,6 @@ describe("RuntimeFlags", () => {
KILO_DISABLE_DEFAULT_PLUGINS: "true",
KILO_DISABLE_EXTERNAL_SKILLS: "true",
KILO_DISABLE_LSP_DOWNLOAD: "true",
KILO_SKIP_MIGRATIONS: "true",
KILO_EXPERIMENTAL: "true",
KILO_ENABLE_EXA: "true",
KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234",
@@ -356,11 +333,9 @@ describe("RuntimeFlags", () => {
expect(flags.pure).toBe(false)
expect(flags.disableDefaultPlugins).toBe(false)
expect(flags.disableChannelDb).toBe(false)
expect(flags.disableEmbeddedWebUi).toBe(false)
expect(flags.disableExternalSkills).toBe(false)
expect(flags.disableLspDownload).toBe(false)
expect(flags.skipMigrations).toBe(false)
expect(flags.disableClaudeCodePrompt).toBe(false)
expect(flags.disableClaudeCodeSkills).toBe(false)
expect(flags.enableExa).toBe(false)
+4 -3
View File
@@ -1,11 +1,12 @@
import { Effect, Layer } from "effect"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
export namespace ProviderTest {
export function model(override: Partial<Provider.Model> = {}): Provider.Model {
const id = override.id ?? ModelID.make("gpt-5.2")
const providerID = override.providerID ?? ProviderID.make("openai")
const id = override.id ?? ModelV2.ID.make("gpt-5.2")
const providerID = override.providerID ?? ProviderV2.ID.make("openai")
return {
id,
providerID,
@@ -1,73 +0,0 @@
import { $ } from "bun"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
const it =
process.platform === "win32"
? (await import("../lib/effect")).testEffect((await import("../../src/file")).File.defaultLayer)
: undefined
describe("file fsmonitor", () => {
if (!it) {
test.skip("status does not start fsmonitor for readonly git checks", () => {})
test.skip("read does not start fsmonitor for git diffs", () => {})
return
}
it.instance(
"status does not start fsmonitor for readonly git checks",
() =>
Effect.gen(function* () {
const { File } = yield* Effect.promise(() => import("../../src/file"))
const { TestInstance } = yield* Effect.promise(() => import("../fixture/fixture"))
const directory = (yield* TestInstance).directory
const target = path.join(directory, "tracked.txt")
yield* Effect.promise(() => fs.writeFile(target, "base\n"))
yield* Effect.promise(() => $`git add tracked.txt`.cwd(directory).quiet())
yield* Effect.promise(() => $`git commit -m init`.cwd(directory).quiet())
yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(directory).quiet())
yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(directory).quiet().nothrow())
yield* Effect.promise(() => fs.writeFile(target, "next\n"))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "new.txt"), "new\n"))
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
expect(before.exitCode).not.toBe(0)
yield* File.use.status()
const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
expect(after.exitCode).not.toBe(0)
}),
{ git: true },
)
it.instance(
"read does not start fsmonitor for git diffs",
() =>
Effect.gen(function* () {
const { File } = yield* Effect.promise(() => import("../../src/file"))
const { TestInstance } = yield* Effect.promise(() => import("../fixture/fixture"))
const directory = (yield* TestInstance).directory
const target = path.join(directory, "tracked.txt")
yield* Effect.promise(() => fs.writeFile(target, "base\n"))
yield* Effect.promise(() => $`git add tracked.txt`.cwd(directory).quiet())
yield* Effect.promise(() => $`git commit -m init`.cwd(directory).quiet())
yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(directory).quiet())
yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(directory).quiet().nothrow())
yield* Effect.promise(() => fs.writeFile(target, "next\n"))
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
expect(before.exitCode).not.toBe(0)
yield* File.use.read("tracked.txt")
const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
expect(after.exitCode).not.toBe(0)
}),
{ git: true },
)
})
@@ -1,10 +0,0 @@
import { test, expect } from "bun:test"
import { FileIgnore } from "../../src/file/ignore"
test("match nested and non-nested", () => {
expect(FileIgnore.match("node_modules/index.js")).toBe(true)
expect(FileIgnore.match("node_modules")).toBe(true)
expect(FileIgnore.match("node_modules/")).toBe(true)
expect(FileIgnore.match("node_modules/bar")).toBe(true)
expect(FileIgnore.match("node_modules/bar/")).toBe(true)
})
-872
View File
@@ -1,872 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { $ } from "bun"
import { Cause, Effect, Exit, Layer } from "effect"
import path from "path"
import fs from "fs/promises"
import { File } from "../../src/file"
import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
const it = testEffect(Layer.mergeAll(File.defaultLayer, AppFileSystem.defaultLayer))
const init = Effect.fn("FileTest.init")(function* () {
const file = yield* File.Service
return yield* file.init()
})
const status = Effect.fn("FileTest.status")(function* () {
const file = yield* File.Service
return yield* file.status()
})
const read = Effect.fn("FileTest.read")(function* (input: string) {
const file = yield* File.Service
return yield* file.read(input)
})
const list = Effect.fn("FileTest.list")(function* (dir?: string) {
const file = yield* File.Service
return yield* file.list(dir)
})
const search = Effect.fn("FileTest.search")(function* (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) {
const file = yield* File.Service
return yield* file.search(input)
})
const gitAddAll = (directory: string) => Effect.promise(() => $`git add .`.cwd(directory).quiet())
const gitCommit = (directory: string, message: string) =>
Effect.promise(() => $`git commit -m ${message}`.cwd(directory).quiet())
const failureMessage = <A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const exit = yield* self.pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
return error instanceof Error ? error.message : String(error)
}
throw new Error("expected effect to fail")
})
const setupSearchableRepo = Effect.fn("FileTest.setupSearchableRepo")(function* (directory: string) {
const fsys = yield* AppFileSystem.Service
yield* fsys.writeWithDirs(path.join(directory, "index.ts"), "code")
yield* fsys.writeWithDirs(path.join(directory, "utils.ts"), "utils")
yield* fsys.writeWithDirs(path.join(directory, "readme.md"), "readme")
yield* fsys.writeWithDirs(path.join(directory, "src", "main.ts"), "main")
yield* fsys.writeWithDirs(path.join(directory, ".hidden", "secret.ts"), "secret")
})
describe("file/index Filesystem patterns", () => {
describe("read() - text content", () => {
it.instance("reads text file via Filesystem.readText()", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "Hello World", "utf-8"))
const result = yield* read("test.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("Hello World")
}),
)
it.instance("reads with Filesystem.exists() check", () =>
Effect.gen(function* () {
const result = yield* read("nonexistent.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("")
}),
)
it.instance("trims whitespace from text content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.txt"), " content with spaces \n\n", "utf-8"),
)
const result = yield* read("test.txt")
expect(result.content).toBe("content with spaces")
}),
)
it.instance("handles empty text file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "empty.txt"), "", "utf-8"))
const result = yield* read("empty.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("")
}),
)
it.instance("handles multi-line text files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8"),
)
const result = yield* read("multiline.txt")
expect(result.content).toBe("line1\nline2\nline3")
}),
)
})
describe("read() - binary content", () => {
it.instance("reads binary file via Filesystem.readArrayBuffer()", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "image.png"), binaryContent))
const result = yield* read("image.png")
expect(result.type).toBe("text")
expect(result.encoding).toBe("base64")
expect(result.mimeType).toBe("image/png")
expect(result.content).toBe(binaryContent.toString("base64"))
}),
)
it.instance("returns empty for binary non-image files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
)
const result = yield* read("binary.so")
expect(result.type).toBe("binary")
expect(result.content).toBe("")
}),
)
})
describe("read() - Filesystem.mimeType()", () => {
it.instance("detects MIME type via Filesystem.mimeType()", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "test.json")
yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8"))
expect(AppFileSystem.mimeType(filepath)).toContain("application/json")
const result = yield* read("test.json")
expect(result.type).toBe("text")
}),
)
it.instance("handles various image MIME types", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const testCases = [
{ ext: "jpg", mime: "image/jpeg" },
{ ext: "png", mime: "image/png" },
{ ext: "gif", mime: "image/gif" },
{ ext: "webp", mime: "image/webp" },
]
for (const testCase of testCases) {
const filepath = path.join(test.directory, `test.${testCase.ext}`)
yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00])))
expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime)
}
}),
)
})
describe("list() - Filesystem.exists() and readText()", () => {
it.instance(
"reads .gitignore via AppFileSystem.existsSafe() and readFileString()",
() =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const test = yield* TestInstance
const gitignorePath = path.join(test.directory, ".gitignore")
yield* fsys.writeFileString(gitignorePath, "node_modules\ndist\n")
expect(yield* fsys.existsSafe(gitignorePath)).toBe(true)
expect(yield* fsys.readFileString(gitignorePath)).toContain("node_modules")
}),
{ git: true },
)
it.instance(
"reads .ignore file similarly",
() =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const test = yield* TestInstance
const ignorePath = path.join(test.directory, ".ignore")
yield* fsys.writeFileString(ignorePath, "*.log\n.env\n")
expect(yield* fsys.existsSafe(ignorePath)).toBe(true)
expect(yield* fsys.readFileString(ignorePath)).toContain("*.log")
}),
{ git: true },
)
it.instance(
"handles missing .gitignore gracefully",
() =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const test = yield* TestInstance
const gitignorePath = path.join(test.directory, ".gitignore")
expect(yield* fsys.existsSafe(gitignorePath)).toBe(false)
const nodes = yield* list()
expect(Array.isArray(nodes)).toBe(true)
}),
{ git: true },
)
})
describe("File.changed() - AppFileSystem.readFileString() for untracked files", () => {
it.instance(
"reads untracked files via AppFileSystem.readFileString()",
() =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const test = yield* TestInstance
const untrackedPath = path.join(test.directory, "untracked.txt")
yield* fsys.writeFileString(untrackedPath, "new content\nwith multiple lines")
const content = yield* fsys.readFileString(untrackedPath)
expect(content.split("\n").length).toBe(2)
}),
{ git: true },
)
})
describe("Error handling", () => {
it.instance("handles errors gracefully in AppFileSystem.readFileString()", () =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const test = yield* TestInstance
yield* fsys.writeFileString(path.join(test.directory, "readonly.txt"), "content")
const nonExistentPath = path.join(test.directory, "does-not-exist.txt")
expect(Exit.isFailure(yield* fsys.readFileString(nonExistentPath).pipe(Effect.exit))).toBe(true)
const result = yield* read("does-not-exist.txt")
expect(result.content).toBe("")
}),
)
it.instance("handles errors in AppFileSystem.readFile()", () =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const test = yield* TestInstance
const nonExistentPath = path.join(test.directory, "does-not-exist.bin")
const buffer = yield* fsys.readFile(nonExistentPath).pipe(Effect.orElseSucceed(() => new Uint8Array(0)))
expect(buffer.byteLength).toBe(0)
}),
)
it.instance("returns empty array buffer on error for images", () =>
Effect.gen(function* () {
const result = yield* read("broken.png")
expect(result.type).toBe("text")
expect(result.content).toBe("")
}),
)
})
describe("shouldEncode() logic", () => {
it.instance("treats .ts files as text", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8"),
)
const result = yield* read("test.ts")
expect(result.type).toBe("text")
expect(result.content).toBe("export const value = 1")
}),
)
it.instance("treats .mts files as text", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8"),
)
const result = yield* read("test.mts")
expect(result.type).toBe("text")
expect(result.content).toBe("export const value = 1")
}),
)
it.instance("treats .sh files as text", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8"),
)
const result = yield* read("test.sh")
expect(result.type).toBe("text")
expect(result.content).toBe("#!/usr/bin/env bash\necho hello")
}),
)
it.instance("treats Dockerfile as text", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "Dockerfile"), "FROM alpine:3.20", "utf-8"))
const result = yield* read("Dockerfile")
expect(result.type).toBe("text")
expect(result.content).toBe("FROM alpine:3.20")
}),
)
it.instance("returns encoding info for text files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "simple text", "utf-8"))
const result = yield* read("test.txt")
expect(result.encoding).toBeUndefined()
expect(result.type).toBe("text")
}),
)
it.instance("returns base64 encoding for images", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0])),
)
const result = yield* read("test.jpg")
expect(result.encoding).toBe("base64")
expect(result.mimeType).toBe("image/jpeg")
}),
)
})
describe("Path security", () => {
it.instance("throws for paths outside project directory", () =>
Effect.gen(function* () {
expect(yield* failureMessage(read("../outside.txt"))).toContain("Access denied")
}),
)
it.instance("throws for paths outside project directory", () =>
Effect.gen(function* () {
expect(yield* failureMessage(read("../outside.txt"))).toContain("Access denied")
}),
)
})
describe("status()", () => {
it.instance(
"detects modified file",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "original\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* Effect.promise(() => fs.writeFile(filepath, "modified\nextra line\n", "utf-8"))
const result = yield* status()
const entry = result.find((file) => file.path === "file.txt")
expect(entry).toBeDefined()
expect(entry!.status).toBe("modified")
expect(entry!.added).toBeGreaterThan(0)
expect(entry!.removed).toBeGreaterThan(0)
}),
{ git: true },
)
it.instance(
"detects untracked file as added",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8"),
)
const result = yield* status()
const entry = result.find((file) => file.path === "new.txt")
expect(entry).toBeDefined()
expect(entry!.status).toBe("added")
expect(entry!.added).toBe(4)
expect(entry!.removed).toBe(0)
}),
{ git: true },
)
it.instance(
"detects deleted file",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "gone.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "content\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* Effect.promise(() => fs.rm(filepath))
const result = yield* status()
const entries = result.filter((file) => file.path === "gone.txt")
expect(entries.some((entry) => entry.status === "deleted")).toBe(true)
}),
{ git: true },
)
it.instance(
"detects mixed changes",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "keep\n", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "remove.txt"), "remove\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "initial")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "changed\n", "utf-8"))
yield* Effect.promise(() => fs.rm(path.join(test.directory, "remove.txt")))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "brand-new.txt"), "hello\n", "utf-8"))
const result = yield* status()
expect(result.some((file) => file.path === "keep.txt" && file.status === "modified")).toBe(true)
expect(result.some((file) => file.path === "remove.txt" && file.status === "deleted")).toBe(true)
expect(result.some((file) => file.path === "brand-new.txt" && file.status === "added")).toBe(true)
}),
{ git: true },
)
it.instance("returns empty for non-git project", () =>
Effect.gen(function* () {
expect(yield* status()).toEqual([])
}),
)
it.instance(
"returns empty for clean repo",
() =>
Effect.gen(function* () {
expect(yield* status()).toEqual([])
}),
{ git: true },
)
it.instance(
"parses binary numstat as 0",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "data.bin")
yield* Effect.promise(() =>
fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index))),
)
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add binary")
yield* Effect.promise(() =>
fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256))),
)
const result = yield* status()
const entry = result.find((file) => file.path === "data.bin")
expect(entry).toBeDefined()
expect(entry!.status).toBe("modified")
expect(entry!.added).toBe(0)
expect(entry!.removed).toBe(0)
}),
{ git: true },
)
})
describe("list()", () => {
it.instance(
"returns files and directories with correct shape",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "subdir")))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "content", "utf-8"))
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8"),
)
const nodes = yield* list()
expect(nodes.length).toBeGreaterThanOrEqual(2)
for (const node of nodes) {
expect(node).toHaveProperty("name")
expect(node).toHaveProperty("path")
expect(node).toHaveProperty("absolute")
expect(node).toHaveProperty("type")
expect(node).toHaveProperty("ignored")
expect(["file", "directory"]).toContain(node.type)
}
}),
{ git: true },
)
it.instance(
"sorts directories before files, alphabetical within each",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "beta")))
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "alpha")))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "zz.txt"), "", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "aa.txt"), "", "utf-8"))
const nodes = yield* list()
const dirs = nodes.filter((node) => node.type === "directory")
const files = nodes.filter((node) => node.type === "file")
const firstFile = nodes.findIndex((node) => node.type === "file")
const lastDir = nodes.findLastIndex((node) => node.type === "directory")
if (lastDir >= 0 && firstFile >= 0) {
expect(lastDir).toBeLessThan(firstFile)
}
expect(dirs.map((dir) => dir.name)).toEqual(dirs.map((dir) => dir.name).toSorted())
expect(files.map((file) => file.name)).toEqual(files.map((file) => file.name).toSorted())
}),
{ git: true },
)
it.instance(
"excludes .git and .DS_Store",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".DS_Store"), "", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "visible.txt"), "", "utf-8"))
const names = (yield* list()).map((node) => node.name)
expect(names).not.toContain(".git")
expect(names).not.toContain(".DS_Store")
expect(names).toContain("visible.txt")
}),
{ git: true },
)
it.instance(
"marks gitignored files as ignored",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".gitignore"), "*.log\nbuild/\n", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "app.log"), "log data", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "main.ts"), "code", "utf-8"))
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "build")))
const nodes = yield* list()
expect(nodes.find((node) => node.name === "app.log")?.ignored).toBe(true)
expect(nodes.find((node) => node.name === "main.ts")?.ignored).toBe(false)
expect(nodes.find((node) => node.name === "build")?.ignored).toBe(true)
}),
{ git: true },
)
it.instance(
"lists subdirectory contents",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "sub")))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "a.txt"), "", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "b.txt"), "", "utf-8"))
const nodes = yield* list("sub")
expect(nodes.length).toBe(2)
expect(nodes.map((node) => node.name).sort()).toEqual(["a.txt", "b.txt"])
expect(nodes[0].path.replaceAll("\\", "/").startsWith("sub/")).toBe(true)
}),
{ git: true },
)
it.instance(
"throws for paths outside project directory",
() =>
Effect.gen(function* () {
expect(yield* failureMessage(list("../outside"))).toContain("Access denied")
}),
{ git: true },
)
it.instance("works without git", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "hi", "utf-8"))
const nodes = yield* list()
expect(nodes.length).toBeGreaterThanOrEqual(1)
for (const node of nodes) {
expect(node.ignored).toBe(false)
}
}),
)
})
describe("search()", () => {
it.instance(
"empty query returns files",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* setupSearchableRepo(test.directory)
yield* init()
const result = yield* search({ query: "", type: "file" })
expect(result.length).toBeGreaterThan(0)
}),
{ git: true },
)
it.instance(
"search works before explicit init",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* setupSearchableRepo(test.directory)
const result = yield* search({ query: "main", type: "file" })
expect(result.some((file) => file.includes("main"))).toBe(true)
}),
{ git: true },
)
it.instance(
"empty query returns dirs sorted with hidden last",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* setupSearchableRepo(test.directory)
yield* init()
const result = yield* search({ query: "", type: "directory" })
expect(result.length).toBeGreaterThan(0)
const firstHidden = result.findIndex((dir) =>
dir.split("/").some((part) => part.startsWith(".") && part.length > 1),
)
const lastVisible = result.findLastIndex(
(dir) => !dir.split("/").some((part) => part.startsWith(".") && part.length > 1),
)
if (firstHidden >= 0 && lastVisible >= 0) {
expect(firstHidden).toBeGreaterThan(lastVisible)
}
}),
{ git: true },
)
it.instance(
"fuzzy matches file names",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* setupSearchableRepo(test.directory)
yield* init()
const result = yield* search({ query: "main", type: "file" })
expect(result.some((file) => file.includes("main"))).toBe(true)
}),
{ git: true },
)
it.instance(
"type filter returns only files",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* setupSearchableRepo(test.directory)
yield* init()
const result = yield* search({ query: "", type: "file" })
for (const file of result) {
expect(file.endsWith("/")).toBe(false)
}
}),
{ git: true },
)
it.instance(
"type filter returns only directories",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* setupSearchableRepo(test.directory)
yield* init()
const result = yield* search({ query: "", type: "directory" })
for (const dir of result) {
expect(dir.endsWith("/")).toBe(true)
}
}),
{ git: true },
)
it.instance(
"respects limit",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* setupSearchableRepo(test.directory)
yield* init()
const result = yield* search({ query: "", type: "file", limit: 2 })
expect(result.length).toBeLessThanOrEqual(2)
}),
{ git: true },
)
it.instance(
"query starting with dot prefers hidden files",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* setupSearchableRepo(test.directory)
yield* init()
const result = yield* search({ query: ".hidden", type: "directory" })
expect(result.length).toBeGreaterThan(0)
expect(result[0]).toContain(".hidden")
}),
{ git: true },
)
it.instance(
"search refreshes after init when files change",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* setupSearchableRepo(test.directory)
yield* init()
expect(yield* search({ query: "fresh", type: "file" })).toEqual([])
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "fresh.ts"), "fresh", "utf-8"))
expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts")
}),
{ git: true },
)
})
describe("read() - diff/patch", () => {
it.instance(
"returns diff and patch for modified tracked file",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "original content\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* Effect.promise(() => fs.writeFile(filepath, "modified content\n", "utf-8"))
const result = yield* read("file.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("modified content")
expect(result.diff).toBeDefined()
expect(result.diff).toContain("original content")
expect(result.diff).toContain("modified content")
expect(result.patch).toBeDefined()
expect(result.patch!.hunks.length).toBeGreaterThan(0)
}),
{ git: true },
)
it.instance(
"returns diff for staged changes",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "staged.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* Effect.promise(() => fs.writeFile(filepath, "after\n", "utf-8"))
yield* gitAddAll(test.directory)
const result = yield* read("staged.txt")
expect(result.diff).toBeDefined()
expect(result.patch).toBeDefined()
}),
{ git: true },
)
it.instance(
"returns no diff for unmodified file",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "clean.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "unchanged\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
const result = yield* read("clean.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("unchanged")
expect(result.diff).toBeUndefined()
expect(result.patch).toBeUndefined()
}),
{ git: true },
)
})
describe("InstanceState isolation", () => {
it.instance(
"two directories get independent file caches",
() =>
Effect.gen(function* () {
const one = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(one.directory, "a.ts"), "one", "utf-8"))
yield* init()
expect(yield* search({ query: "a.ts", type: "file" })).toContain("a.ts")
expect(yield* search({ query: "b.ts", type: "file" })).not.toContain("b.ts")
yield* Effect.gen(function* () {
const two = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(two.directory, "b.ts"), "two", "utf-8"))
yield* init()
expect(yield* search({ query: "b.ts", type: "file" })).toContain("b.ts")
expect(yield* search({ query: "a.ts", type: "file" })).not.toContain("a.ts")
}).pipe(withTmpdirInstance({ git: true }))
}),
{ git: true },
)
it.instance(
"disposal gives fresh state on next access",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "before.ts"), "before", "utf-8"))
yield* init()
expect(yield* search({ query: "before", type: "file" })).toContain("before.ts")
yield* Effect.promise(() => disposeAllInstances())
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "after.ts"), "after", "utf-8"))
yield* Effect.promise(() => fs.rm(path.join(test.directory, "before.ts")))
yield* init()
expect(yield* search({ query: "after", type: "file" })).toContain("after.ts")
expect(yield* search({ query: "before", type: "file" })).not.toContain("before.ts")
}),
{ git: true },
)
})
})
@@ -1,185 +0,0 @@
import { expect, describe } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import { File } from "../../src/file"
import { InstanceState } from "../../src/effect/instance-state"
import { containsPath } from "../../src/project/instance-context"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(File.defaultLayer)
const read = (file: string) => File.use.read(file)
const list = (dir?: string) => File.use.list(dir)
const expectAccessDenied = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const exit = yield* effect.pipe(Effect.exit)
if (Exit.isSuccess(exit)) throw new Error("expected access denied")
expect(Cause.squash(exit.cause)).toHaveProperty("message", "Access denied: path escapes project directory")
})
describe("Filesystem.contains", () => {
it.effect("allows paths within project", () =>
Effect.sync(() => {
expect(Filesystem.contains("/project", "/project/src")).toBe(true)
expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true)
expect(Filesystem.contains("/project", "/project")).toBe(true)
}),
)
it.effect("blocks ../ traversal", () =>
Effect.sync(() => {
expect(Filesystem.contains("/project", "/project/../etc")).toBe(false)
expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false)
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
}),
)
it.effect("blocks absolute paths outside project", () =>
Effect.sync(() => {
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
expect(Filesystem.contains("/project", "/tmp/file")).toBe(false)
expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false)
}),
)
it.effect("handles prefix collision edge cases", () =>
Effect.sync(() => {
expect(Filesystem.contains("/project", "/project-other/file")).toBe(false)
expect(Filesystem.contains("/project", "/projectfile")).toBe(false)
}),
)
})
/*
* Integration tests for read() and list() path traversal protection.
*
* These tests verify the HTTP API code path is protected. The HTTP endpoints
* in server.ts (GET /file/content, GET /file) call read()/list()
* directly - they do NOT go through ReadTool or the agent permission layer.
*
* This is a SEPARATE code path from ReadTool, which has its own checks.
*/
describe("File.read path traversal protection", () => {
it.instance("rejects ../ traversal attempting to read /etc/passwd", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "allowed.txt"), "allowed content"))
yield* expectAccessDenied(read("../../../etc/passwd"))
}),
)
it.instance("rejects deeply nested traversal", () =>
Effect.gen(function* () {
yield* expectAccessDenied(read("src/nested/../../../../../../../etc/passwd"))
}),
)
it.instance("allows valid paths within project", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "valid.txt"), "valid content"))
const result = yield* read("valid.txt")
expect(result.content).toBe("valid content")
}),
)
})
describe("File.list path traversal protection", () => {
it.instance("rejects ../ traversal attempting to list /etc", () =>
Effect.gen(function* () {
yield* expectAccessDenied(list("../../../etc"))
}),
)
it.instance("allows valid subdirectory listing", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "subdir", "file.txt"), "content"))
const result = yield* list("subdir")
expect(Array.isArray(result)).toBe(true)
}),
)
})
describe("containsPath", () => {
it.instance(
"returns true for path inside directory",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceState.context
expect(containsPath(path.join(test.directory, "foo.txt"), ctx)).toBe(true)
expect(containsPath(path.join(test.directory, "src", "file.ts"), ctx)).toBe(true)
}),
{ git: true },
)
it.instance(
"returns true for path inside worktree but outside directory (monorepo subdirectory scenario)",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const subdir = path.join(test.directory, "packages", "lib")
yield* Effect.promise(() => fs.mkdir(subdir, { recursive: true }))
const ctx = { ...(yield* InstanceState.context), directory: subdir }
// .opencode at worktree root, but we're running from packages/lib
expect(containsPath(path.join(test.directory, ".opencode", "state"), ctx)).toBe(true)
// sibling package should also be accessible
expect(containsPath(path.join(test.directory, "packages", "other", "file.ts"), ctx)).toBe(true)
// worktree root itself
expect(containsPath(test.directory, ctx)).toBe(true)
}),
{ git: true },
)
it.instance(
"returns false for path outside both directory and worktree",
() =>
Effect.gen(function* () {
const ctx = yield* InstanceState.context
expect(containsPath("/etc/passwd", ctx)).toBe(false)
expect(containsPath("/tmp/other-project", ctx)).toBe(false)
}),
{ git: true },
)
it.instance(
"returns false for path with .. escaping worktree",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceState.context
expect(containsPath(path.join(test.directory, "..", "escape.txt"), ctx)).toBe(false)
}),
{ git: true },
)
it.instance(
"handles directory === worktree (running from repo root)",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceState.context
expect(ctx.directory).toBe(ctx.worktree)
expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true)
expect(containsPath("/etc/passwd", ctx)).toBe(false)
}),
{ git: true },
)
it.instance("non-git project does not allow arbitrary paths via worktree='/'", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceState.context
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true)
expect(containsPath("/etc/passwd", ctx)).toBe(false)
expect(containsPath("/tmp/other", ctx)).toBe(false)
}),
)
})
-220
View File
@@ -1,220 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Ripgrep } from "../../src/file/ripgrep"
import { testEffect } from "../lib/effect"
const it = testEffect(Ripgrep.defaultLayer)
const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
Effect.acquireRelease(
Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
(dir) =>
Effect.promise(() =>
fs.rm(dir, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
}),
).pipe(Effect.ignore),
).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
const collectFiles = (input: Ripgrep.FilesInput) =>
Ripgrep.Service.use((rg) =>
rg.files(input).pipe(
Stream.runCollect,
Effect.map((c) => [...c]),
),
)
const withRipgrepConfig = <A, E, R>(value: string, effect: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const prev = process.env["RIPGREP_CONFIG_PATH"]
process.env["RIPGREP_CONFIG_PATH"] = value
return prev
}),
() => effect,
(prev) =>
Effect.sync(() => {
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
else process.env["RIPGREP_CONFIG_PATH"] = prev
}),
)
describe("file.ripgrep", () => {
it.live("defaults to include hidden", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* write(path.join(dir, "visible.txt"), "hello")
yield* mkdir(path.join(dir, ".opencode"))
yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
}),
)
const files = yield* collectFiles({ cwd: dir })
expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
}),
)
it.live("hidden false excludes hidden", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* write(path.join(dir, "visible.txt"), "hello")
yield* mkdir(path.join(dir, ".opencode"))
yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
}),
)
const files = yield* collectFiles({ cwd: dir, hidden: false })
expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
}),
)
it.live("search returns empty when nothing matches", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const value = 'other'\n"))
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
expect(result.partial).toBe(false)
expect(result.items).toEqual([])
}),
)
it.live("search returns match metadata with normalized path", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* mkdir(path.join(dir, "src"))
yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
}),
)
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
expect(result.items[0]?.line_number).toBe(1)
expect(result.items[0]?.lines.text).toContain("needle")
}),
)
it.live("search returns matched rows with glob filter", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
yield* write(path.join(dir, "skip.txt"), "const value = 'other'\n")
}),
)
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", glob: ["*.ts"] })
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toContain("match.ts")
expect(result.items[0]?.lines.text).toContain("needle")
}),
)
it.live("search supports explicit file targets", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
yield* write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
}),
)
const file = path.join(dir, "match.ts")
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", file: [file] })
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toBe(file)
}),
)
it.live("files returns empty when glob matches no files", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* mkdir(path.join(dir, "packages", "console"))
yield* write(path.join(dir, "packages", "console", "package.json"), "{}")
}),
)
const files = yield* collectFiles({ cwd: dir, glob: ["packages/*"] })
expect(files).toEqual([])
}),
)
it.live("files returns stream of filenames", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* write(path.join(dir, "a.txt"), "hello")
yield* write(path.join(dir, "b.txt"), "world")
}),
)
const files = yield* collectFiles({ cwd: dir }).pipe(Effect.map((files) => files.sort()))
expect(files).toEqual(["a.txt", "b.txt"])
}),
)
it.live("files respects glob filter", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* write(path.join(dir, "keep.ts"), "yes")
yield* write(path.join(dir, "skip.txt"), "no")
}),
)
const files = yield* collectFiles({ cwd: dir, glob: ["*.ts"] })
expect(files).toEqual(["keep.ts"])
}),
)
it.live("files dies on nonexistent directory", () =>
Effect.gen(function* () {
const exit = yield* Ripgrep.Service.use((rg) =>
rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect),
).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
it.live("ignores RIPGREP_CONFIG_PATH in direct mode", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
const result = yield* withRipgrepConfig(
path.join(dir, "missing-ripgreprc"),
Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
)
expect(result.items).toHaveLength(1)
}),
)
it.live("ignores RIPGREP_CONFIG_PATH in worker mode", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
const result = yield* withRipgrepConfig(
path.join(dir, "missing-ripgreprc"),
Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
)
expect(result.items).toHaveLength(1)
}),
)
})
-352
View File
@@ -1,352 +0,0 @@
import { describe, expect } from "bun:test"
import path from "path"
import { realpath } from "fs/promises"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { ConfigProvider, Deferred, Duration, Effect, Layer, Option } from "effect"
import { TestInstance, provideInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { Config } from "@/config/config"
import { FileWatcher } from "../../src/file/watcher"
import { Git } from "../../src/git"
// kilocode_change start
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows).
const describeWatcher =
FileWatcher.hasNativeBinding() && (!process.env.CI || process.env.KILO_TEST_PROFILE === "darwin")
? describe
: describe.skip
// kilocode_change end
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const watcherConfigLayer = ConfigProvider.layer(
ConfigProvider.fromUnknown({
KILO_EXPERIMENTAL_FILEWATCHER: "true",
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
}),
)
const watcherLayer = FileWatcher.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(watcherConfigLayer),
)
const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, Git.defaultLayer))
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcher service. */
function withWatcher<A, E, R>(directory: string, body: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const watcher = yield* FileWatcher.Service
yield* watcher.init()
yield* ready(directory)
return yield* body
}).pipe(Effect.provide(watcherLayer), provideInstance(directory), Effect.scoped)
}
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
let done = false
const on = (evt: GlobalEvent) => {
if (done) return
if (evt.directory !== directory) return
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
if (!check(evt.payload.properties)) return
hit(evt.payload.properties)
}
GlobalBus.on("event", on)
return () => {
if (done) return
done = true
GlobalBus.off("event", on)
}
}
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
return Effect.gen(function* () {
const deferred = yield* Deferred.make<WatcherEvent>()
const cleanup = yield* Effect.sync(() => {
let off = () => {}
off = listen(directory, check, (evt) => {
off()
Effect.runFork(Deferred.succeed(deferred, evt))
})
return off
})
return { cleanup, deferred }
})
}
function maybeNextUpdate<E>(
directory: string,
check: (evt: WatcherEvent) => boolean,
trigger: Effect.Effect<void, E>,
timeout: Duration.Input = "5 seconds",
) {
return Effect.acquireUseRelease(
wait(directory, check),
({ deferred }) =>
Effect.gen(function* () {
yield* trigger
return yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeout))
}),
({ cleanup }) => Effect.sync(cleanup),
)
}
function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
return Effect.gen(function* () {
const result = yield* maybeNextUpdate(directory, check, trigger)
if (Option.isSome(result)) return result.value
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
})
}
function eventuallyUpdate<E>(
directory: string,
check: (evt: WatcherEvent) => boolean,
trigger: () => Effect.Effect<void, E>,
) {
return Effect.gen(function* () {
while (true) {
const result = yield* maybeNextUpdate(directory, check, trigger(), "250 millis")
if (Option.isSome(result)) return result.value
}
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
}),
)
}
/** Effect that asserts no matching event arrives within `ms`. */
function noUpdate<E>(
directory: string,
check: (evt: WatcherEvent) => boolean,
trigger: Effect.Effect<void, E>,
ms = 500,
) {
return Effect.acquireUseRelease(
wait(directory, check),
({ deferred }) =>
Effect.gen(function* () {
yield* trigger
const result = yield* Deferred.await(deferred).pipe(
Effect.map((evt) => Option.some(evt)),
Effect.timeoutOrElse({ duration: `${ms} millis`, orElse: () => Effect.succeed(Option.none()) }),
)
expect(result).toEqual(Option.none())
}),
({ cleanup }) => Effect.sync(cleanup),
)
}
function ready(directory: string) {
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
const head = path.join(directory, ".git", "HEAD")
return Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
yield* eventuallyUpdate(
directory,
(evt) => evt.file === file,
() => fs.writeFileString(file, `ready-${Math.random()}`),
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
if (!(yield* fs.existsSafe(head))) return
const realHead = yield* Effect.promise(() => realpath(head).catch(() => head))
const hash = (yield* git.run(["rev-parse", "HEAD"], { cwd: directory })).text()
yield* eventuallyUpdate(
directory,
(evt) => (evt.file === head || evt.file === realHead) && evt.event !== "unlink",
() => {
const branch = `watch-${Math.random().toString(36).slice(2)}`
return fs
.writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
.pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`)))
},
).pipe(Effect.asVoid)
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describeWatcher("FileWatcher", () => {
it.instance(
"publishes root create, update, and delete events",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const file = path.join(test.directory, "watch.txt")
const cases = [
{ event: "add" as const, trigger: fs.writeFileString(file, "a") },
{ event: "change" as const, trigger: fs.writeFileString(file, "b") },
{ event: "unlink" as const, trigger: fs.remove(file) },
]
yield* withWatcher(
test.directory,
Effect.forEach(cases, ({ event, trigger }) =>
nextUpdate(test.directory, (evt) => evt.file === file && evt.event === event, trigger).pipe(
Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
),
),
)
}),
{ git: true },
)
it.instance("watches non-git roots", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const file = path.join(test.directory, "plain.txt")
yield* withWatcher(
test.directory,
nextUpdate(test.directory, (e) => e.file === file && e.event === "add", fs.writeFileString(file, "plain")).pipe(
Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" }))),
),
)
}),
)
it.instance(
"cleanup stops publishing events",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const file = path.join(test.directory, "after-dispose.txt")
// Start and immediately stop the watcher (withWatcher disposes on exit).
yield* withWatcher(test.directory, Effect.void)
// Now write a file - no watcher should be listening.
yield* noUpdate(test.directory, (e) => e.file === file, fs.writeFileString(file, "gone")).pipe(
provideInstance(test.directory),
)
}),
{ git: true },
)
it.instance(
"ignores .git/index changes",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const gitIndex = path.join(test.directory, ".git", "index")
const edit = path.join(test.directory, "tracked.txt")
yield* withWatcher(
test.directory,
noUpdate(
test.directory,
(e) => e.file === gitIndex,
fs.writeFileString(edit, "a").pipe(Effect.andThen(git.run(["add", "."], { cwd: test.directory }))),
),
)
}),
{ git: true },
)
it.instance(
"publishes .git/HEAD events",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const head = path.join(test.directory, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* git.run(["branch", branch], { cwd: test.directory })
yield* withWatcher(
test.directory,
nextUpdate(
test.directory,
(evt) => evt.file === head && evt.event !== "unlink",
fs.writeFileString(head, `ref: refs/heads/${branch}\n`),
).pipe(
Effect.tap((evt) =>
Effect.sync(() => {
expect(evt.file).toBe(head)
expect(["add", "change"]).toContain(evt.event)
}),
),
),
)
}),
{ git: true },
)
// Symlink support varies by platform; skip where unavailable
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
describeSymlink("symlinked .git", () => {
it.instance(
"publishes .git/HEAD events through a symlinked .git directory",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const dir = test.directory
const actualGit = path.join(dir, "..", "tmp_actual_git_" + Math.random().toString(36).slice(2))
// Move .git to a sibling directory and replace with a symlink
yield* Effect.promise(() => import("fs")).pipe(
Effect.flatMap((nodeFs) =>
Effect.all([
Effect.promise(() => nodeFs.promises.rename(path.join(dir, ".git"), actualGit)),
Effect.promise(() => nodeFs.promises.symlink(actualGit, path.join(dir, ".git"))),
]),
),
)
yield* Effect.acquireRelease(Effect.succeed(actualGit), (p) =>
Effect.promise(() =>
import("fs").then((f) => f.promises.rm(p, { recursive: true, force: true }).catch(() => undefined)),
),
)
const head = path.join(dir, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* git.run(["branch", branch], { cwd: dir })
yield* withWatcher(
dir,
nextUpdate(
dir,
(evt) => evt.file === path.join(actualGit, "HEAD") && evt.event !== "unlink",
fs.writeFileString(head, `ref: refs/heads/${branch}\n`),
).pipe(
Effect.tap((evt) =>
Effect.sync(() => {
expect(evt.file).toBe(path.join(actualGit, "HEAD"))
expect(["add", "change"]).toContain(evt.event)
}),
),
),
)
}),
{ git: true },
)
})
})
@@ -1,19 +1,19 @@
import { describe, test, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { testEffect } from "../lib/effect"
import path from "path"
const live = AppFileSystem.layer.pipe(Layer.provide(NodeFileSystem.layer))
const live = FSUtil.layer.pipe(Layer.provide(NodeFileSystem.layer))
const { effect: it } = testEffect(live)
describe("AppFileSystem", () => {
describe("FSUtil", () => {
describe("isDir", () => {
it(
"returns true for directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
expect(yield* fs.isDir(tmp)).toBe(true)
}),
@@ -22,7 +22,7 @@ describe("AppFileSystem", () => {
it(
"returns false for files",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "test.txt")
yield* fs.writeFileString(file, "hello")
@@ -33,7 +33,7 @@ describe("AppFileSystem", () => {
it(
"returns false for non-existent paths",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
}),
)
@@ -43,7 +43,7 @@ describe("AppFileSystem", () => {
it(
"returns true for files",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "test.txt")
yield* fs.writeFileString(file, "hello")
@@ -54,7 +54,7 @@ describe("AppFileSystem", () => {
it(
"returns false for directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
expect(yield* fs.isFile(tmp)).toBe(false)
}),
@@ -65,7 +65,7 @@ describe("AppFileSystem", () => {
it(
"round-trips JSON data",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "data.json")
const data = { name: "test", count: 42, nested: { ok: true } }
@@ -82,7 +82,7 @@ describe("AppFileSystem", () => {
it(
"creates nested directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const nested = path.join(tmp, "a", "b", "c")
@@ -96,7 +96,7 @@ describe("AppFileSystem", () => {
it(
"is idempotent",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const dir = path.join(tmp, "existing")
yield* fs.makeDirectory(dir)
@@ -113,7 +113,7 @@ describe("AppFileSystem", () => {
it(
"creates parent directories if missing",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "deep", "nested", "file.txt")
@@ -126,7 +126,7 @@ describe("AppFileSystem", () => {
it(
"writes directly when parent exists",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "direct.txt")
@@ -139,7 +139,7 @@ describe("AppFileSystem", () => {
it(
"writes Uint8Array content",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "binary.bin")
const content = new Uint8Array([0x00, 0x01, 0x02, 0x03])
@@ -156,7 +156,7 @@ describe("AppFileSystem", () => {
it(
"finds target in start directory",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "target.txt"), "found")
@@ -168,7 +168,7 @@ describe("AppFileSystem", () => {
it(
"finds target in parent directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "marker"), "root")
const child = path.join(tmp, "a", "b")
@@ -182,7 +182,7 @@ describe("AppFileSystem", () => {
it(
"returns empty array when not found",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const result = yield* fs.findUp("nonexistent", tmp, tmp)
expect(result).toEqual([])
@@ -194,7 +194,7 @@ describe("AppFileSystem", () => {
it(
"finds multiple targets walking up",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "a.txt"), "a")
yield* fs.writeFileString(path.join(tmp, "b.txt"), "b")
@@ -215,7 +215,7 @@ describe("AppFileSystem", () => {
it(
"finds files matching pattern",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "a.ts"), "a")
yield* fs.writeFileString(path.join(tmp, "b.ts"), "b")
@@ -229,7 +229,7 @@ describe("AppFileSystem", () => {
it(
"supports absolute paths",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "file.txt"), "hello")
@@ -243,7 +243,7 @@ describe("AppFileSystem", () => {
it(
"matches patterns",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
@@ -255,7 +255,7 @@ describe("AppFileSystem", () => {
it(
"finds files walking up directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "root.md"), "root")
const child = path.join(tmp, "a", "b")
@@ -273,7 +273,7 @@ describe("AppFileSystem", () => {
it(
"exists works",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "exists.txt")
yield* fs.writeFileString(file, "yes")
@@ -286,7 +286,7 @@ describe("AppFileSystem", () => {
it(
"remove works",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "delete-me.txt")
yield* fs.writeFileString(file, "bye")
@@ -300,20 +300,20 @@ describe("AppFileSystem", () => {
describe("pure helpers", () => {
test("mimeType returns correct types", () => {
expect(AppFileSystem.mimeType("file.json")).toBe("application/json")
expect(AppFileSystem.mimeType("image.png")).toBe("image/png")
expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream")
expect(FSUtil.mimeType("file.json")).toBe("application/json")
expect(FSUtil.mimeType("image.png")).toBe("image/png")
expect(FSUtil.mimeType("unknown.qzx")).toBe("application/octet-stream")
})
test("contains checks path containment", () => {
expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true)
expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false)
expect(FSUtil.contains("/a/b", "/a/b/c")).toBe(true)
expect(FSUtil.contains("/a/b", "/a/c")).toBe(false)
})
test("overlaps detects overlapping paths", () => {
expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true)
expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true)
expect(AppFileSystem.overlaps("/a", "/b")).toBe(false)
expect(FSUtil.overlaps("/a/b", "/a/b/c")).toBe(true)
expect(FSUtil.overlaps("/a/b/c", "/a/b")).toBe(true)
expect(FSUtil.overlaps("/a", "/b")).toBe(false)
})
})
})
+1 -1
View File
@@ -1,5 +1,5 @@
import { Config } from "@/config/config"
import { emptyConsoleState } from "@/config/console-state"
import { emptyConsoleState } from "@opencode-ai/core/v1/config/console-state"
import { Effect, Layer } from "effect"
export function make(overrides: Partial<Config.Interface> = {}) {
+8 -6
View File
@@ -1,13 +1,15 @@
import { Database } from "@/storage/db"
import { rm } from "fs/promises"
import { Database } from "@opencode-ai/core/database/database"
import { disposeAllInstances } from "./fixture"
export async function resetDatabase() {
// kilocode_change start
// Closing the in-memory connection clears shared test state. Never reset a
// disk-backed database because this helper can run without the test preload.
const path = Database.getPath()
if (path !== ":memory:") throw new Error(`Refusing to reset non-test database: ${path}`)
// Never reset a disk-backed database because this helper can run without the test preload.
const dbPath = Database.path()
if (dbPath !== ":memory:") throw new Error(`Refusing to reset non-test database: ${dbPath}`)
// kilocode_change end
await disposeAllInstances().catch(() => undefined)
Database.close()
await rm(dbPath, { force: true }).catch(() => undefined)
await rm(`${dbPath}-wal`, { force: true }).catch(() => undefined)
await rm(`${dbPath}-shm`, { force: true }).catch(() => undefined)
}
+61 -32
View File
@@ -1,5 +1,6 @@
import { $ } from "bun"
import * as Observability from "@opencode-ai/core/effect/observability"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import * as fs from "fs/promises"
import os from "os"
import path from "path"
@@ -7,6 +8,10 @@ import { Effect, Context, Layer, ManagedRuntime } from "effect"
import type * as PlatformError from "effect/PlatformError"
import type * as Scope from "effect/Scope"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Database } from "@opencode-ai/core/database/database" // kilocode_change
import { ProjectV2 } from "@opencode-ai/core/project" // kilocode_change
import { ProjectTable } from "@opencode-ai/core/project/sql" // kilocode_change
import { AbsolutePath } from "@opencode-ai/core/schema" // kilocode_change
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { Config } from "@/config/config"
import { InstanceRef } from "../../src/effect/instance-ref"
@@ -19,10 +24,19 @@ import { remove as cleanup } from "../kilocode/cleanup" // kilocode_change
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
export const testInstanceStoreLayer = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
const testInstanceRuntime = ManagedRuntime.make(testInstanceStoreLayer.pipe(Layer.provideMerge(Observability.layer)))
const makeTestRuntime = () =>
ManagedRuntime.make(testInstanceStoreLayer.pipe(Layer.provideMerge(Observability.layer)))
let testRuntime: ReturnType<typeof makeTestRuntime> | undefined
const runtime = () => (testRuntime ??= makeTestRuntime())
const runTestInstanceStore = <A>(fn: (store: InstanceStore.Interface) => Effect.Effect<A>) =>
testInstanceRuntime.runPromise(InstanceStore.Service.use(fn))
runtime().runPromise(InstanceStore.Service.use(fn))
export async function disposeTestRuntime() {
const rt = testRuntime
testRuntime = undefined
await rt?.dispose()
}
export async function provideTestInstance<R>(input: {
directory: string
@@ -31,7 +45,7 @@ export async function provideTestInstance<R>(input: {
}) {
const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory }))
try {
if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx)))
if (input.init) await runtime().runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx)))
return await instanceContext.provide(ctx, () => input.fn(ctx)) // kilocode_change
} finally {
// kilocode_change start
@@ -78,7 +92,7 @@ async function stop(dir: string) {
type TmpDirOptions<T> = {
git?: boolean
config?: Partial<Config.Info>
config?: Partial<ConfigV1.Info>
init?: (dir: string) => Promise<T>
dispose?: (dir: string) => Promise<T>
}
@@ -120,9 +134,10 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
}
/** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */
export function tmpdirScoped(options?: {
export function tmpdirScoped<E = never, R = never>(options?: {
git?: boolean
config?: Partial<Config.Info> | (() => Partial<Config.Info>)
config?: Partial<ConfigV1.Info> | (() => Partial<ConfigV1.Info>)
init?: (directory: string) => Effect.Effect<void, E, R>
}) {
return Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
@@ -159,19 +174,16 @@ export function tmpdirScoped(options?: {
)
}
if (options?.init) yield* options.init(dir)
return dir
})
}
export const provideInstance =
(directory: string) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.contextWith((services: Context.Context<R>) =>
Effect.promise<A>(async () => {
const ctx = await runTestInstanceStore((store) => store.load({ directory }))
return Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx)))
}),
)
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | InstanceStore.Service> =>
InstanceStore.Service.use((store) => store.provide({ directory }, self))
export const provideInstanceEffect =
(directory: string) =>
@@ -185,27 +197,40 @@ export const disposeAllInstancesEffect = InstanceStore.Service.use((store) => st
export function provideTmpdirInstance<A, E, R>(
self: (path: string) => Effect.Effect<A, E, R>,
options?: { git?: boolean; config?: Partial<Config.Info> | (() => Partial<Config.Info>) },
options?: { git?: boolean; config?: Partial<ConfigV1.Info> | (() => Partial<ConfigV1.Info>) },
) {
return Effect.gen(function* () {
const path = yield* tmpdirScoped(options)
let provided = false
yield* Effect.addFinalizer(() =>
provided
? Effect.promise(() =>
runTestInstanceStore((store) =>
store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx))),
),
).pipe(Effect.ignore)
: Effect.void,
)
provided = true
return yield* self(path).pipe(provideInstance(path))
})
}).pipe(Effect.provide(testInstanceStoreLayer))
}
// kilocode_change start - custom test runtimes need the instance project in their core database
export const seedProject = Effect.gen(function* () {
const ctx = yield* InstanceRef
if (!ctx) return yield* Effect.die(new Error("missing test instance"))
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({
id: ProjectV2.ID.make(ctx.project.id),
worktree: AbsolutePath.make(ctx.project.worktree),
vcs: ctx.project.vcs,
sandboxes: ctx.project.sandboxes.map((path) => AbsolutePath.make(path)),
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
})
export function provideTmpdirProject<A, E, R>(
self: (path: string) => Effect.Effect<A, E, R>,
options?: { git?: boolean; config?: Partial<ConfigV1.Info> | (() => Partial<ConfigV1.Info>) },
) {
return provideTmpdirInstance((path) => seedProject.pipe(Effect.andThen(self(path))), options)
}
// kilocode_change end
export class TestInstance extends Context.Service<TestInstance, { readonly directory: string }>()("@test/Instance") {}
export const requireInstance = Effect.gen(function* () {
@@ -215,7 +240,11 @@ export const requireInstance = Effect.gen(function* () {
})
export const withTmpdirInstance =
(options?: { git?: boolean; config?: Partial<Config.Info> | (() => Partial<Config.Info>) }) =>
<E2 = never, R2 = never>(options?: {
git?: boolean
config?: Partial<ConfigV1.Info> | (() => Partial<ConfigV1.Info>)
init?: (directory: string) => Effect.Effect<void, E2, R2>
}) =>
<A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped(options)
@@ -224,15 +253,15 @@ export const withTmpdirInstance =
export function provideTmpdirServer<A, E, R>(
self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> },
options?: { git?: boolean; config?: (url: string) => Partial<ConfigV1.Info> },
): Effect.Effect<
A,
E | PlatformError.PlatformError,
R | TestLLMServer | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
R | Database.Service | TestLLMServer | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
> {
return Effect.gen(function* () {
const llm = yield* TestLLMServer
return yield* provideTmpdirInstance((dir) => self({ dir, llm }), {
return yield* provideTmpdirProject((dir) => self({ dir, llm }), {
git: options?.git,
config: options?.config?.(llm.url),
})
+2 -2
View File
@@ -1,4 +1,4 @@
import type { WorkspaceID } from "@/control-plane/schema"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect, Scope } from "effect"
@@ -7,7 +7,7 @@ import { Effect, Scope } from "effect"
* on entry and restores it via finalizer when the surrounding scope closes —
* preserves the original try/finally semantics regardless of test outcome.
*/
export function withFixedWorkspaceID(id: WorkspaceID): Effect.Effect<void, never, Scope.Scope> {
export function withFixedWorkspaceID(id: WorkspaceV2.ID): Effect.Effect<void, never, Scope.Scope> {
return Effect.gen(function* () {
const previous = Flag.KILO_WORKSPACE_ID
Flag.KILO_WORKSPACE_ID = id
+6 -4
View File
@@ -1,6 +1,7 @@
import { FetchHttpClient } from "effect/unstable/http"
import { Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Database } from "@opencode-ai/core/database/database"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Auth } from "../../src/auth"
import { Workspace } from "../../src/control-plane/workspace"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
@@ -10,18 +11,19 @@ import { Project } from "../../src/project/project"
import { Vcs } from "../../src/project/vcs"
import { Session } from "../../src/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { SyncEvent } from "../../src/sync"
import { EventV2Bridge } from "../../src/event-v2-bridge"
export const workspaceLayerWithRuntimeFlags = (overrides: Partial<RuntimeFlags.Info>) =>
Workspace.layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(RuntimeFlags.layer(overrides)),
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
+178 -222
View File
@@ -1,7 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { provideTmpdirInstance } from "../fixture/fixture"
import { provideTmpdirInstance, testInstanceStoreLayer, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Format } from "../../src/format"
@@ -10,141 +10,104 @@ import * as Formatter from "../../src/format/formatter"
const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
describe("Format", () => {
it.live("status() returns empty list when no formatters are configured", () =>
provideTmpdirInstance(() =>
it.instance("status() returns empty list when no formatters are configured", () =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
expect(yield* fmt.status()).toEqual([])
}),
),
)
it.instance(
"status() returns built-in formatters when formatter is true",
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
expect(yield* fmt.status()).toEqual([])
const statuses = yield* fmt.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
expect(gofmt).toBeDefined()
expect(gofmt!.extensions).toContain(".go")
}),
),
),
{ config: { formatter: true } },
)
it.live("status() returns built-in formatters when formatter is true", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
expect(gofmt).toBeDefined()
expect(gofmt!.extensions).toContain(".go")
}),
),
{
config: {
formatter: true,
},
},
),
)
it.live("status() keeps built-in formatters when config object is provided", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
const mix = statuses.find((item) => item.name === "mix")
expect(gofmt).toBeDefined()
expect(gofmt!.extensions).toContain(".go")
expect(mix).toBeDefined()
}),
),
{
config: {
formatter: {
gofmt: {},
},
},
},
),
)
it.live("status() excludes formatters marked as disabled in config", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
const mix = statuses.find((item) => item.name === "mix")
expect(gofmt).toBeUndefined()
expect(mix).toBeDefined()
}),
),
{
config: {
formatter: {
gofmt: { disabled: true },
},
},
},
),
)
it.live("status() excludes uv when ruff is disabled", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
}),
),
{
config: {
formatter: {
ruff: { disabled: true },
},
},
},
),
)
it.live("status() excludes ruff when uv is disabled", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
}),
),
{
config: {
formatter: {
uv: { disabled: true },
},
},
},
),
)
it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void)))
it.live("file() returns false when no formatter runs", () =>
provideTmpdirInstance(
(dir) =>
it.instance(
"status() keeps built-in formatters when config object is provided",
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const file = `${dir}/test.txt`
yield* Effect.promise(() => Bun.write(file, "x"))
const formatted = yield* Format.use.file(file)
expect(formatted).toBe(false)
const statuses = yield* fmt.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
const mix = statuses.find((item) => item.name === "mix")
expect(gofmt).toBeDefined()
expect(gofmt!.extensions).toContain(".go")
expect(mix).toBeDefined()
}),
{
config: {
formatter: false,
},
},
),
),
{ config: { formatter: { gofmt: {} } } },
)
it.live("status() initializes formatter state per directory", () =>
it.instance(
"status() excludes formatters marked as disabled in config",
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
const mix = statuses.find((item) => item.name === "mix")
expect(gofmt).toBeUndefined()
expect(mix).toBeDefined()
}),
),
{ config: { formatter: { gofmt: { disabled: true } } } },
)
it.instance(
"status() excludes uv when ruff is disabled",
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
}),
),
{ config: { formatter: { ruff: { disabled: true } } } },
)
it.instance(
"status() excludes ruff when uv is disabled",
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
}),
),
{ config: { formatter: { uv: { disabled: true } } } },
)
it.instance("service initializes without error", () => Format.Service.use(() => Effect.void))
it.instance(
"file() returns false when no formatter runs",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = `${test.directory}/test.txt`
yield* Effect.promise(() => Bun.write(file, "x"))
const formatted = yield* Format.use.file(file)
expect(formatted).toBe(false)
}),
{ config: { formatter: false } },
)
testEffect(
Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer),
).live("status() initializes formatter state per directory", () =>
Effect.gen(function* () {
const a = yield* provideTmpdirInstance(() => Format.use.status(), {
config: { formatter: false },
@@ -160,113 +123,106 @@ describe("Format", () => {
}),
)
it.live("runs enabled checks for matching formatters in parallel", () =>
provideTmpdirInstance(
(path) =>
Effect.gen(function* () {
const file = `${path}/test.parallel`
yield* Effect.promise(() => Bun.write(file, "x"))
it.instance(
"runs enabled checks for matching formatters in parallel",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = `${test.directory}/test.parallel`
yield* Effect.promise(() => Bun.write(file, "x"))
const one = {
extensions: Formatter.gofmt.extensions,
enabled: Formatter.gofmt.enabled,
}
const two = {
extensions: Formatter.mix.extensions,
enabled: Formatter.mix.enabled,
}
const one = {
extensions: Formatter.gofmt.extensions,
enabled: Formatter.gofmt.enabled,
}
const two = {
extensions: Formatter.mix.extensions,
enabled: Formatter.mix.enabled,
}
let active = 0
let max = 0
let active = 0
let max = 0
yield* Effect.acquireUseRelease(
Effect.sync(() => {
Formatter.gofmt.extensions = [".parallel"]
Formatter.mix.extensions = [".parallel"]
Formatter.gofmt.enabled = async () => {
active++
max = Math.max(max, active)
await Promise.resolve()
active--
return ["sh", "-c", "true"]
}
Formatter.mix.enabled = async () => {
active++
max = Math.max(max, active)
await Promise.resolve()
active--
return ["sh", "-c", "true"]
}
}),
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
yield* fmt.init()
yield* fmt.file(file)
}),
),
() =>
Effect.sync(() => {
Formatter.gofmt.extensions = one.extensions
Formatter.gofmt.enabled = one.enabled
Formatter.mix.extensions = two.extensions
Formatter.mix.enabled = two.enabled
yield* Effect.acquireUseRelease(
Effect.sync(() => {
Formatter.gofmt.extensions = [".parallel"]
Formatter.mix.extensions = [".parallel"]
Formatter.gofmt.enabled = async () => {
active++
max = Math.max(max, active)
await Promise.resolve()
active--
return ["sh", "-c", "true"]
}
Formatter.mix.enabled = async () => {
active++
max = Math.max(max, active)
await Promise.resolve()
active--
return ["sh", "-c", "true"]
}
}),
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
yield* fmt.init()
yield* fmt.file(file)
}),
)
),
() =>
Effect.sync(() => {
Formatter.gofmt.extensions = one.extensions
Formatter.gofmt.enabled = one.enabled
Formatter.mix.extensions = two.extensions
Formatter.mix.enabled = two.enabled
}),
)
expect(max).toBe(2)
}),
{
config: {
formatter: {
gofmt: {},
mix: {},
},
},
},
),
expect(max).toBe(2)
}),
{ config: { formatter: { gofmt: {}, mix: {} } } },
)
it.live("runs matching formatters sequentially for the same file", () =>
provideTmpdirInstance(
(path) =>
Effect.gen(function* () {
const file = `${path}/test.seq`
yield* Effect.promise(() => Bun.write(file, "x"))
it.instance(
"runs matching formatters sequentially for the same file",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = `${test.directory}/test.seq`
yield* Effect.promise(() => Bun.write(file, "x"))
yield* Format.Service.use((fmt) =>
Effect.gen(function* () {
yield* fmt.init()
expect(yield* fmt.file(file)).toBe(true)
}),
)
yield* Format.Service.use((fmt) =>
Effect.gen(function* () {
yield* fmt.init()
expect(yield* fmt.file(file)).toBe(true)
}),
)
expect(yield* Effect.promise(() => Bun.file(file).text())).toBe("xAB")
}),
{
config: {
formatter: {
first: {
command: [
"node",
"-e",
"const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')",
"$FILE",
],
extensions: [".seq"],
},
second: {
command: [
"node",
"-e",
"const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')",
"$FILE",
],
extensions: [".seq"],
},
expect(yield* Effect.promise(() => Bun.file(file).text())).toBe("xAB")
}),
{
config: {
formatter: {
first: {
command: [
"node",
"-e",
"const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')",
"$FILE",
],
extensions: [".seq"],
},
second: {
command: [
"node",
"-e",
"const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')",
"$FILE",
],
extensions: [".seq"],
},
},
},
),
},
)
})
@@ -202,8 +202,9 @@ describe("installation", () => {
testEffect(
testLayer(
() => new Response("install script with token=secret", { status: 200 }),
(cmd) => {
if (cmd === "bash") return { code: 1, stderr: "script output with token=secret" }
(cmd, args) => {
if (cmd === "bash" && args[0] === "--version") return "GNU bash"
if (cmd === "bash" || cmd === "sh") return { code: 1, stderr: "script output with token=secret" }
return ""
},
),
@@ -217,5 +218,21 @@ describe("installation", () => {
expect(error.stderr).not.toContain("script output")
}),
)
testEffect(
testLayer(
() => new Response("install script", { status: 200 }),
(cmd, args) => {
if (cmd === "bash" && args[0] === "--version") return { code: 1, stderr: "missing" }
if (cmd === "bash") return { code: 1, stderr: "should not execute installer with bash" }
if (cmd === "sh") return "ok"
return ""
},
),
).effect("falls back to sh when bash is unavailable during curl upgrade", () =>
Effect.gen(function* () {
yield* Installation.use.upgrade("curl", "9.9.9")
}),
)
})
})
@@ -10,7 +10,8 @@ import { Tool } from "../../src/tool/tool"
import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { Provider } from "../../src/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
const providers = {
test: {
@@ -105,8 +106,8 @@ function message(
time: { created },
agent: "build",
model: {
providerID: ProviderID.make(provider),
modelID: ModelID.make(model),
providerID: ProviderV2.ID.make(provider),
modelID: ModelV2.ID.make(model),
...(variant ? { variant } : {}),
},
},
@@ -3,10 +3,15 @@ import { Effect } from "effect"
import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission"
import { provideTestInstance } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdir } from "../fixture/fixture"
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
return Effect.runPromise(
provideInstance(dir)(Agent.Service.use(fn)).pipe(
Effect.provide(Agent.defaultLayer),
Effect.provide(testInstanceStoreLayer),
),
)
}
afterEach(async () => {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import { ConfigAgent } from "@/config/agent"
import { ConfigAgentV1 } from "@opencode-ai/core/v1/config/agent"
import { ConfigParse } from "@/config/parse"
import * as AgentRequirements from "@/kilocode/agent-requirements"
import type { MCP } from "@/mcp"
@@ -291,7 +291,7 @@ describe("agent requirements", () => {
test("keeps requirements out of agent options", () => {
const agent = ConfigParse.schema(
ConfigAgent.Info,
ConfigAgentV1.Info,
{
name: "demo",
requirements: { skills: ["needed"] },
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { FetchHttpClient } from "effect/unstable/http"
import { Effect, Layer, Redacted } from "effect"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
@@ -162,10 +162,10 @@ function fixture(settings: Settings = {}) {
home: value.home,
env: { PATH: path.join(value.home, "bin") },
}
const platform = DesktopPlatform.makeLayer(info).pipe(Layer.provide(AppFileSystem.defaultLayer))
const platform = DesktopPlatform.makeLayer(info).pipe(Layer.provide(FSUtil.defaultLayer))
const layer = Discovery.makeLayer({ timeout: "100 millis" }).pipe(
Layer.provide(platform),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
return { ...value, layer }
@@ -10,7 +10,7 @@ import type { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent"
import { Truncate } from "../../src/tool/truncate"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Plugin } from "../../src/plugin"
import { Config } from "../../src/config/config"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
@@ -18,7 +18,7 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
const runtime = ManagedRuntime.make(
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
FSUtil.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import { messages, parse } from "../../src/kilocode/branch-name"
import { MessageV2 } from "../../src/session/message-v2"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
function user(text: string, synthetic = false): MessageV2.WithParts {
@@ -15,8 +16,8 @@ function user(text: string, synthetic = false): MessageV2.WithParts {
time: { created: Date.now() },
agent: "code",
model: {
providerID: ProviderID.make("kilo"),
modelID: ModelID.make("kilo-auto/small"),
providerID: ProviderV2.ID.make("kilo"),
modelID: ModelV2.ID.make("kilo-auto/small"),
},
},
parts: [
+4 -5
View File
@@ -15,15 +15,14 @@ function locked(error: unknown) {
export async function remove(dir: string) {
const cfg = opts()
const state = { gc: false }
const rm = async (left: number): Promise<void> => {
return fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(async (error) => {
if (!locked(error)) throw error
if (left <= 1) throw error
if (process.platform === "win32" && !state.gc) {
Bun.gc(true)
state.gc = true
}
// bun:sqlite connections release their file handles on GC finalization, not on Effect scope
// closure, so Windows needs a GC pass per retry: a connection that becomes unreachable after
// a single early pass would otherwise never finalize while this loop only sleeps.
if (process.platform === "win32") Bun.gc(true)
await Bun.sleep(cfg.delay)
return rm(left - 1)
})
@@ -36,6 +36,12 @@ mock.module("@kilocode/kilo-gateway", () => ({
async migrateLegacyKiloAuth() {},
}))
mock.module("@/effect/app-runtime", () => ({
AppRuntime: {
async runPromise() {},
},
}))
mock.module("@/config/config", () => ({
Config: { Service: { use: () => ({ experimental: {} }) } },
}))
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { CodexAuthExpiredError, refreshCodexAuth } from "../../src/kilocode/provider/codex-refresh"
import { MessageV2 } from "../../src/session/message-v2"
import { ProviderID } from "../../src/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { spawn } from "child_process"
import fs from "fs/promises"
import os from "os"
@@ -139,7 +139,7 @@ async function race(input: { reuse: "early" | "late"; delay: number; lock?: Lock
describe("Codex auth refresh", () => {
test("serializes expired Codex auth as ProviderAuthError", () => {
const result = MessageV2.fromError(new CodexAuthExpiredError(), { providerID: ProviderID.make("openai") })
const result = MessageV2.fromError(new CodexAuthExpiredError(), { providerID: ProviderV2.ID.make("openai") })
expect(result).toStrictEqual({
name: "ProviderAuthError",
@@ -4,7 +4,7 @@ import * as Sink from "effect/Sink"
import * as TestClock from "effect/testing/TestClock"
import { ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { CommandTimeout } from "@/kilocode/command-timeout"
import { ShellTool } from "@/tool/shell"
import { Plugin } from "@/plugin"
@@ -23,7 +23,7 @@ const it = testEffect(Layer.empty)
const shell = testEffect(
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
FSUtil.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Config.defaultLayer,
@@ -3,6 +3,7 @@ import { APICallError } from "ai"
import { Effect, Layer, ManagedRuntime, Scope } from "effect"
import * as Stream from "effect/Stream"
import { LLMEvent, type LLMEvent as Event } from "@opencode-ai/llm"
import { Database } from "@opencode-ai/core/database/database"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
@@ -14,7 +15,8 @@ import { KiloSessionCompaction } from "../../src/kilocode/session/compaction"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { provideTestInstance } from "../fixture/fixture"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Snapshot } from "../../src/snapshot"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
@@ -32,32 +34,31 @@ import { tmpdir } from "../fixture/fixture"
const sessionID = SessionID.make("ses_payload_recovery")
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const providerID = ProviderID.make("test")
const modelID = ModelID.make("test-model")
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("test-model")
const ref = {
providerID,
modelID,
}
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
messages(input: Parameters<SessionNs.Interface["messages"]>[0]) {
return run(SessionNs.Service.use((svc) => svc.messages(input)))
},
updateMessage<T extends MessageV2.Info>(msg: T) {
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
},
updatePart<T extends MessageV2.Part>(part: T) {
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
},
function service(rt: ReturnType<typeof runtime>) {
return {
create(input?: SessionNs.CreateInput) {
return rt.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
},
messages(input: Parameters<SessionNs.Interface["messages"]>[0]) {
return rt.runPromise(SessionNs.Service.use((svc) => svc.messages(input)))
},
updateMessage<T extends MessageV2.Info>(msg: T) {
return rt.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
},
updatePart<T extends MessageV2.Part>(part: T) {
return rt.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
},
}
}
type Store = ReturnType<typeof service>
const summary = Layer.succeed(
SessionSummary.Service,
@@ -76,7 +77,7 @@ function base(id: MessageID) {
}
}
async function user(sessionID: SessionID, text: string) {
async function user(svc: Store, sessionID: SessionID, text: string) {
const msg = await svc.updateMessage({
id: MessageID.ascending(),
role: "user",
@@ -95,7 +96,7 @@ async function user(sessionID: SessionID, text: string) {
return msg
}
async function assistant(sessionID: SessionID, parentID: MessageID, root: string) {
async function assistant(svc: Store, sessionID: SessionID, parentID: MessageID, root: string) {
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
@@ -166,7 +167,7 @@ function runtime(layer: Layer.Layer<LLM.Service>, config = Config.defaultLayer)
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
Layer.provide(ProviderTest.fake({ model }).layer),
Layer.provide(SessionNs.defaultLayer),
Layer.provideMerge(SessionNs.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(layer),
Layer.provide(Permission.defaultLayer),
@@ -180,6 +181,7 @@ function runtime(layer: Layer.Layer<LLM.Service>, config = Config.defaultLayer)
Layer.provide(Reference.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(Reference.defaultLayer),
),
)
@@ -297,8 +299,10 @@ describe("KiloCompactionPayloadRecovery", () => {
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const rt = runtime(stub.layer, Config.defaultLayer)
const svc = service(rt)
const session = await svc.create({})
const old = await user(session.id, "old image turn")
const old = await user(svc, session.id, "old image turn")
await svc.updatePart({
id: PartID.ascending(),
messageID: old.id,
@@ -308,7 +312,7 @@ describe("KiloCompactionPayloadRecovery", () => {
filename: "old.png",
url: `data:image/png;base64,${"a".repeat(8_000)}`,
})
const oldReply = await assistant(session.id, old.id, tmp.path)
const oldReply = await assistant(svc, session.id, old.id, tmp.path)
await svc.updatePart({
id: PartID.ascending(),
messageID: oldReply.id,
@@ -325,9 +329,9 @@ describe("KiloCompactionPayloadRecovery", () => {
time: { start: Date.now(), end: Date.now() },
},
})
await user(session.id, "latest turn")
const keep = await user(session.id, "preserved tail turn")
const keepReply = await assistant(session.id, keep.id, tmp.path)
await user(svc, session.id, "latest turn")
const keep = await user(svc, session.id, "preserved tail turn")
const keepReply = await assistant(svc, session.id, keep.id, tmp.path)
await svc.updatePart({
id: PartID.ascending(),
messageID: keepReply.id,
@@ -357,7 +361,6 @@ describe("KiloCompactionPayloadRecovery", () => {
}),
)
const rt = runtime(stub.layer, Config.defaultLayer)
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
@@ -14,7 +14,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../src/config/config"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Npm } from "@opencode-ai/core/npm"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Env } from "../../src/env"
import { Git } from "../../src/git"
import { Auth } from "../../src/auth"
@@ -51,7 +51,7 @@ const unexpectedHttp = HttpClient.make((request) =>
const testLayer = Config.layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
@@ -4,7 +4,7 @@ import { Effect, Layer, Option, Schema } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { Npm } from "@opencode-ai/core/npm"
@@ -43,7 +43,7 @@ const unexpectedHttp = HttpClient.make((request) =>
const layer = Config.layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
@@ -8,12 +8,13 @@ import { Account } from "../../../src/account/account"
import { Auth } from "../../../src/auth"
import { Config } from "../../../src/config/config"
import type { ConfigPlugin } from "../../../src/config/plugin"
import type { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
import { KilocodeDefaultPlugins } from "../../../src/kilocode/config/default-plugins"
import { INDEXING_PLUGIN } from "../../../src/kilocode/indexing-feature"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { Env } from "../../../src/env"
import { Git } from "../../../src/git"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Filesystem } from "../../../src/util/filesystem"
import { provideTestInstance } from "../../fixture/fixture"
@@ -42,7 +43,7 @@ const unexpectedHttp = HttpClient.make((request) =>
const layer = Config.layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
@@ -58,7 +59,7 @@ describe("kilocode default indexing plugin", () => {
})
test("injects indexing without registering an external plugin origin", () => {
const config: { plugin?: ConfigPlugin.Spec[]; plugin_origins?: ConfigPlugin.Origin[] } = {}
const config: { plugin?: ConfigPluginV1.Spec[]; plugin_origins?: ConfigPlugin.Origin[] } = {}
KilocodeDefaultPlugins.apply(config, { disabled: false })
@@ -4,7 +4,7 @@ import path from "node:path"
import { expect, test } from "bun:test"
import { ConfigVariable } from "@/config/variable"
import { ConfigVariableGuard } from "@/kilocode/config/variable"
import { InvalidError } from "@/config/error"
import { InvalidError } from "@opencode-ai/core/v1/config/error"
const source = { type: "virtual" as const, source: "test", dir: process.cwd() }
const trusted = { ...source, trusted: true }
@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { containsPath, type InstanceContext } from "../../src/project/instance-context"
// Restores the boundary coverage lost with test/file/path-traversal.test.ts. The
// "inside directory OR worktree, except worktree === '/'" policy is Kilo-facing: it
// gates plan files, background processes, shell permissions, config classification,
// and LSP filtering, where a worktree path outside the working directory must not
// trigger the external_directory permission.
const root = path.resolve("/repo")
// containsPath only reads directory and worktree.
const ctx = (input: { directory: string; worktree: string }) => input as unknown as InstanceContext
describe("containsPath", () => {
test("allows paths inside the working directory", () => {
const c = ctx({ directory: root, worktree: root })
expect(containsPath(path.join(root, "foo.txt"), c)).toBe(true)
expect(containsPath(path.join(root, "src", "file.ts"), c)).toBe(true)
expect(containsPath(root, c)).toBe(true)
})
test("allows worktree paths outside a nested working directory (monorepo subdirectory)", () => {
const c = ctx({ directory: path.join(root, "packages", "lib"), worktree: root })
expect(containsPath(path.join(root, ".opencode", "state"), c)).toBe(true)
expect(containsPath(path.join(root, "packages", "other", "file.ts"), c)).toBe(true)
expect(containsPath(root, c)).toBe(true)
})
test("rejects paths outside both directory and worktree", () => {
const c = ctx({ directory: path.join(root, "packages", "lib"), worktree: root })
expect(containsPath(path.resolve("/etc/passwd"), c)).toBe(false)
expect(containsPath(path.resolve("/tmp/other-project"), c)).toBe(false)
})
test("rejects .. escapes and prefix collisions", () => {
const c = ctx({ directory: root, worktree: root })
expect(containsPath(path.join(root, "..", "escape.txt"), c)).toBe(false)
expect(containsPath(path.join(root, "src", "..", "..", "etc"), c)).toBe(false)
expect(containsPath(`${root}-other${path.sep}file`, c)).toBe(false)
})
test("worktree '/' (non-git project) does not allow arbitrary paths", () => {
const c = ctx({ directory: path.join(root, "project"), worktree: "/" })
expect(containsPath(path.join(root, "project", "file.txt"), c)).toBe(true)
expect(containsPath(path.resolve("/etc/passwd"), c)).toBe(false)
expect(containsPath(path.resolve("/tmp/other"), c)).toBe(false)
})
})
@@ -0,0 +1 @@
import "../../../core/test/filesystem/watcher.test"
@@ -8,10 +8,12 @@ import { Bus } from "../../src/bus"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { KiloCostPropagation } from "../../src/kilocode/session/cost-propagation"
import { Instance } from "../../src/kilocode/instance"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Session } from "../../src/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID } from "../../src/session/schema"
import { Database } from "@opencode-ai/core/database/database"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -23,11 +25,13 @@ afterEach(async () => {
})
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
providerID: ProviderV2.ID.make("test"),
modelID: ModelV2.ID.make("test-model"),
}
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer))
const it = testEffect(
Layer.mergeAll(Session.defaultLayer, Bus.layer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer),
)
const seed = Effect.fn("CostPropagationTest.seed")(function* () {
const sessions = yield* Session.Service
@@ -10,21 +10,23 @@ import { EditTool } from "../../src/tool/edit"
import { provideTestInstance } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { LSP } from "../../src/lsp/lsp"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema"
const runtime = ManagedRuntime.make(
Layer.mergeAll(
LSP.defaultLayer,
AppFileSystem.defaultLayer,
FSUtil.defaultLayer,
Format.defaultLayer,
Bus.layer,
Truncate.defaultLayer,
Agent.defaultLayer,
EventV2Bridge.defaultLayer,
),
)
@@ -9,7 +9,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
import { assertExternalDirectory } from "../../src/tool/external-directory"
import type { Tool } from "../../src/tool/tool"
import { Filesystem } from "../../src/util/filesystem"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { tmpdir } from "../fixture/fixture"
const base: Omit<Tool.Context, "ask"> = {
@@ -23,7 +23,7 @@ const base: Omit<Tool.Context, "ask"> = {
}
const glob = (p: string) =>
process.platform === "win32" ? AppFileSystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
process.platform === "win32" ? FSUtil.normalizePathPattern(p) : p.replaceAll("\\", "/")
const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
@@ -87,11 +87,11 @@ describe("kilocode external directory boundaries", () => {
test("contains helpers keep dot-prefixed child names internal", () => {
expect(Filesystem.contains("/project", "/project/..cache/file")).toBe(true)
expect(AppFileSystem.contains("/a/b", "/a/b/..cache/file")).toBe(true)
expect(FSUtil.contains("/a/b", "/a/b/..cache/file")).toBe(true)
})
test("AppFileSystem.contains rejects cross-drive paths on Windows", () => {
test("FSUtil.contains rejects cross-drive paths on Windows", () => {
if (process.platform !== "win32") return
expect(AppFileSystem.contains("C:\\repo", "D:\\outside\\file.txt")).toBe(false)
expect(FSUtil.contains("C:\\repo", "D:\\outside\\file.txt")).toBe(false)
})
})
@@ -6,7 +6,7 @@ import { CodeIndexManager } from "@kilocode/kilo-indexing/engine"
import { normalizeIndexingStatus } from "@kilocode/kilo-indexing/status"
import type { Config } from "../../src/config/config"
import { GlobalBus } from "../../src/bus/global"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { WorkspaceContext } from "../../src/control-plane/workspace-context"
import { KiloIndexing } from "../../src/kilocode/indexing"
import { indexingWarningKey } from "../../src/kilocode/indexing-warning"
@@ -255,7 +255,7 @@ describe("indexing startup degradation", () => {
GlobalBus.on("event", on)
try {
const workspace = WorkspaceID.make("wrk_indexing_warning")
const workspace = WorkspaceV2.ID.make("wrk_indexing_warning")
await WorkspaceContext.provide({
workspaceID: workspace,
fn: () =>
@@ -291,7 +291,7 @@ describe("indexing startup degradation", () => {
expect(workspaces.every((item) => item === undefined || item === workspace)).toBe(true)
const offset = events.length
const second = WorkspaceID.make("wrk_indexing_warning_second")
const second = WorkspaceV2.ID.make("wrk_indexing_warning_second")
await WorkspaceContext.provide({
workspaceID: second,
fn: () =>
@@ -215,7 +215,7 @@ describe("Kilo installation upgrade", () => {
Effect.gen(function* () {
yield* Installation.Service.use((svc) => svc.upgrade("curl", "9.9.9"))
expect(curl).toContain("https://kilo.ai/cli/install")
expect(curl).toContain("bash")
expect(curl).toContain("sh")
}),
)
})
@@ -5,13 +5,13 @@ import { Effect, FileSystem, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { NodeFileSystem } from "@effect/platform-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Reference } from "../../src/reference/reference"
import { Instruction } from "../../src/session/instruction"
import { Global } from "@opencode-ai/core/global"
import { TestConfig } from "../fixture/config"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const reference = Layer.mock(Reference.Service)({
@@ -22,7 +22,13 @@ const reference = Layer.mock(Reference.Service)({
contains: () => Effect.succeed(false),
})
const it = testEffect(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, reference, RuntimeFlags.layer()),
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
NodeFileSystem.layer,
reference,
RuntimeFlags.layer(),
testInstanceStoreLayer,
),
)
const configLayer = TestConfig.layer()
@@ -30,7 +36,7 @@ const configLayer = TestConfig.layer()
const instructionLayer = (global: Partial<Global.Interface>) =>
Instruction.layer.pipe(
Layer.provide(configLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Global.layerWith(global)),
)
@@ -13,7 +13,7 @@ import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncate"
import type { Tool } from "@/tool/tool"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import path from "path"
@@ -22,7 +22,7 @@ import { it, testEffect } from "../lib/effect"
const toolLayer = Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
FSUtil.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Config.defaultLayer,
@@ -163,7 +163,7 @@ describe("InteractiveTerminal", () => {
const ext = requests.find((item) => item.permission === "external_directory")
const bash = requests.find((item) => item.permission === "bash")
const want =
process.platform === "win32" ? AppFileSystem.normalizePathPattern(path.join(tmp, "*")) : path.join(tmp, "*")
process.platform === "win32" ? FSUtil.normalizePathPattern(path.join(tmp, "*")) : path.join(tmp, "*")
expect(ext?.patterns).toContain(want)
expect(bash?.patterns).toContain(`cat ${quote(file)}`)
}),
@@ -2,7 +2,7 @@
// Tests that unauthenticated Kilo models are assembled with paid models and autoloaded anonymously.
import { expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { ModelsDev } from "../../src/provider/models"
import * as CoreModels from "@opencode-ai/core/models-dev"
import { Effect, Layer } from "effect"
@@ -13,7 +13,7 @@ import { ModelCache } from "../../src/provider/model-cache"
import { Provider } from "../../src/provider/provider"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
import { provideInstance } from "../fixture/fixture"
import { provideInstance, testInstanceStoreLayer } from "../fixture/fixture"
const input = {
id: "kilo",
@@ -48,16 +48,16 @@ const auth = Layer.mock(Auth.Service)({
})
const files = Layer.effect(
AppFileSystem.Service,
FSUtil.Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return AppFileSystem.Service.of({
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
readJson: () => Effect.succeed(seed),
stat: () => fs.stat(import.meta.path),
})
}),
).pipe(Layer.provide(AppFileSystem.defaultLayer))
).pipe(Layer.provide(FSUtil.defaultLayer))
function load(data?: { auth?: object; config?: object; env?: Record<string, string | undefined> }) {
return kiloCustomLoaders({
@@ -117,7 +117,7 @@ function layer() {
)
}
const it = testEffect(Layer.empty)
const it = testEffect(testInstanceStoreLayer)
it.live("assembles paid Kilo models without auth", () =>
Effect.gen(function* () {
@@ -4,16 +4,20 @@ import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Auth } from "../../src/auth"
import { Bus } from "../../src/bus"
import { GlobalBus } from "../../src/bus/global"
import type { Config } from "../../src/config/config"
import { clearInFlightCache } from "../../src/kilo-sessions/inflight-cache"
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
import { ProjectID } from "../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Session } from "../../src/session/session"
import { SessionID } from "../../src/session/schema"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
import { InstanceStore } from "../../src/project/instance-store"
import { TestInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
const multi = testEffect(Layer.merge(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer))
function layer(overrides: Partial<Config.Interface> = {}) {
return Layer.merge(
@@ -154,22 +158,29 @@ it.instance("does not duplicate created-session subscribers when init is repeate
return Effect.gen(function* () {
const auth = yield* Auth.Service
const bus = yield* Bus.Service
const instance = yield* TestInstance
const sessions = yield* KiloSessions.Service
yield* auth.set("kilo", { type: "api", key: "test-token" })
yield* sessions.init()
yield* sessions.init()
yield* Effect.sleep(50)
yield* bus.publish(Session.Event.Created, {
sessionID: id,
info: {
id,
slug: "test",
projectID: ProjectID.make("project-test"),
directory: "/tmp/test",
title: "test",
version: "test",
time: { created: Date.now(), updated: Date.now() },
GlobalBus.emit("event", {
directory: instance.directory,
payload: {
id: "test-event",
type: Session.Event.Created.type,
properties: {
sessionID: id,
info: {
id,
slug: "test",
projectID: ProjectV2.ID.make("project-test"),
directory: instance.directory,
title: "test",
version: "test",
time: { created: Date.now(), updated: Date.now() },
},
},
},
})
yield* Effect.sleep(50)
@@ -186,3 +197,74 @@ it.instance("does not duplicate created-session subscribers when init is repeate
Effect.provide(layer()),
)
})
multi.live("isolates the process-wide listener by instance directory", () => {
const calls: string[] = []
const fetch: typeof globalThis.fetch = Object.assign(
async (input: RequestInfo | URL) => {
const url = String(input)
if (url.endsWith("/api/user")) return new Response("{}", { status: 200 })
if (url.endsWith("/api/session")) {
calls.push(url)
return Response.json({ id: "remote-1", ingestPath: "/api/ingest/session-1" })
}
return new Response("{}", { status: 200 })
},
{ preconnect: globalThis.fetch.preconnect },
)
const request = spyOn(globalThis, "fetch").mockImplementation(fetch)
reset("test-token")
return Effect.gen(function* () {
const first = yield* tmpdirScoped()
const second = yield* tmpdirScoped()
const auth = yield* Auth.Service
const store = yield* InstanceStore.Service
const sessions = yield* KiloSessions.Service
yield* auth.set("kilo", { type: "api", key: "test-token" })
yield* store.provide({ directory: first }, sessions.init())
yield* store.provide({ directory: second }, sessions.init())
const emit = (directory: string, value: string) => {
const id = SessionID.descending(`session-${value}`)
GlobalBus.emit("event", {
directory,
payload: {
id: `event-${value}`,
type: Session.Event.Created.type,
properties: {
sessionID: id,
info: {
id,
slug: value,
projectID: ProjectV2.ID.make(`project-${value}`),
directory,
title: value,
version: "test",
time: { created: Date.now(), updated: Date.now() },
},
},
},
})
}
emit(first, "first")
yield* Effect.sleep(50)
expect(calls).toHaveLength(1)
emit(second, "second")
yield* Effect.sleep(50)
expect(calls).toHaveLength(2)
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove("kilo").pipe(Effect.orDie)
reset("test-token")
request.mockRestore()
}),
),
Effect.provide(layer()),
)
})
@@ -0,0 +1,137 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Queue, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Bus } from "../../src/bus"
import { GlobalBus } from "../../src/bus/global"
import { Changed } from "../../src/kilocode/sandbox/event"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { SessionID } from "../../src/session/schema"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, requireInstance, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "../server/httpapi-layer"
type Frame = {
type?: string
properties?: Record<string, unknown>
syncEvent?: {
type?: string
aggregateID?: string
data?: unknown
}
}
const parse = (value: unknown): Frame => (typeof value === "object" && value !== null ? (value as Frame) : {})
const take = (reader: Queue.Dequeue<unknown>, match: (frame: Frame) => boolean) =>
Effect.gen(function* () {
while (true) {
const frame = parse(yield* Queue.take(reader))
if (match(frame)) return frame
}
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for SSE event")),
}),
)
const open = (directory: string) =>
Effect.gen(function* () {
const response = yield* requestInDirectory(EventPaths.event, directory)
expect(response.status).toBe(200)
const reader = yield* Queue.unbounded<unknown>()
yield* response.stream.pipe(
Stream.decodeText(),
Stream.pipeThroughChannel(Sse.decode()),
Stream.runForEach((event) => Effect.sync(() => Queue.offerUnsafe(reader, JSON.parse(event.data) as unknown))),
Effect.forkScoped,
)
expect((yield* take(reader, (frame) => frame.type === "server.connected")).properties).toEqual({})
return reader
})
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const it = testEffect(httpApiLayer)
describe("legacy instance SSE", () => {
it.instance(
"delivers legacy Bus events without leaking another directory or workspace",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const ctx = yield* requireInstance
const reader = yield* open(directory)
const foreign = SessionID.make("ses_sse_foreign")
const local = SessionID.make("ses_sse_local")
yield* Effect.promise(() =>
Bus.publish({ ...ctx, directory: `${directory}-foreign` }, Changed, {
sessionID: foreign,
directory: `${directory}-foreign`,
enabled: true,
available: true,
version: 1,
}),
)
GlobalBus.emit("event", {
directory,
workspace: "wrk_foreign",
payload: {
type: Changed.type,
properties: {
sessionID: foreign,
directory,
enabled: true,
available: true,
version: 1,
},
},
})
yield* Effect.promise(() =>
Bus.publish(ctx, Changed, {
sessionID: local,
directory,
enabled: true,
available: true,
version: 2,
}),
)
expect((yield* take(reader, (frame) => frame.type === Changed.type)).properties).toMatchObject({
sessionID: local,
directory,
version: 2,
})
}),
{ git: true, config: { formatter: false, lsp: false } },
)
it.instance(
"delivers versioned sync envelopes from EventV2",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* open(directory)
const response = yield* requestInDirectory("/session", directory, { method: "POST" })
expect(response.status).toBe(200)
const session = (yield* response.json) as { id: string }
const frame = yield* take(
reader,
(event) => event.type === "sync" && event.syncEvent?.type === "session.created.1",
)
expect(frame.syncEvent).toMatchObject({
type: "session.created.1",
aggregateID: session.id,
data: { sessionID: session.id, info: { id: session.id } },
})
}),
{ git: true, config: { formatter: false, lsp: false } },
)
})
@@ -16,7 +16,7 @@ import { MemoryPaths } from "@kilocode/kilo-memory/effect/paths"
import { MemoryEvents } from "../../../src/kilocode/memory/events"
import type { Provider } from "../../../src/provider/provider"
import type { InstanceContext } from "../../../src/project/instance-context"
import { ProjectID } from "../../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionID } from "../../../src/session/schema"
import { provideTestInstance, tmpdir } from "../../fixture/fixture"
@@ -48,7 +48,7 @@ function ctx(dir: string): InstanceContext {
directory: dir,
worktree: dir,
project: {
id: ProjectID.make("project"),
id: ProjectV2.ID.make("project"),
worktree: dir,
vcs: "git",
time: { created: 0, updated: 0 },
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { ModelNotFoundError, type Provider } from "../../../src/provider/provider"
import { ModelID, ProviderID } from "../../../src/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import type { MessageV2 } from "../../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../../src/session/schema"
import type { Session } from "../../../src/session/session"
@@ -10,8 +11,8 @@ import type { SessionSummary } from "../../../src/session/summary"
import type { Snapshot } from "../../../src/snapshot"
import { MemoryModel, MemorySession } from "../../../src/kilocode/memory/ports"
const pid = ProviderID.make("test")
const mid = ModelID.make("fake-memory-model")
const pid = ProviderV2.ID.make("test")
const mid = ModelV2.ID.make("fake-memory-model")
function mdl(id = mid): Provider.Model {
return {
@@ -58,7 +59,7 @@ function lang(outputs = ["{}"]): LanguageModelV3 {
function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider.Interface {
const base = mdl()
const mem = mdl(ModelID.make("memory-config-model"))
const mem = mdl(ModelV2.ID.make("memory-config-model"))
const info = {
id: pid,
name: "Test",
@@ -6,8 +6,7 @@ import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Env } from "../../src/env"
import { Provider } from "../../src/provider/provider"
import { ProviderID } from "../../src/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, CrossSpawnSpawner.defaultLayer))
function withNvidiaKey<A, E, R>(self: Effect.Effect<A, E, R>) {
@@ -25,7 +24,7 @@ it.live("nvidia provider includes KiloCode billing origin header", () =>
Provider.Service.use((provider) =>
Effect.gen(function* () {
const providers = yield* provider.list()
const headers = providers[ProviderID.make("nvidia")].options.headers
const headers = providers[ProviderV2.ID.make("nvidia")].options.headers
expect(headers["HTTP-Referer"]).toBe("https://kilo.ai/")
expect(headers["X-Title"]).toBe("Kilo Code")
@@ -61,7 +60,7 @@ it.live("nvidia billing origin header can be overridden from config", () =>
Provider.Service.use((provider) =>
Effect.gen(function* () {
const providers = yield* provider.list()
const headers = providers[ProviderID.make("nvidia")].options.headers
const headers = providers[ProviderV2.ID.make("nvidia")].options.headers
expect(headers["HTTP-Referer"]).toBe("https://kilo.ai/")
expect(headers["X-Title"]).toBe("Kilo Code")
@@ -14,22 +14,25 @@ import iconv from "iconv-lite"
import { Effect, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Format } from "../../src/format"
import { LSP } from "../../src/lsp/lsp"
import { MessageID, SessionID } from "../../src/session/schema"
import { ApplyPatchTool } from "../../src/tool/apply_patch"
import { Tool } from "../../src/tool/tool"
import { Truncate } from "../../src/tool/truncate"
import { provideInstance } from "../fixture/fixture"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { provideInstance, testInstanceStoreLayer } from "../fixture/fixture"
import { FSUtil } from "@opencode-ai/core/fs-util"
const layer = Layer.mergeAll(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
FSUtil.defaultLayer,
Bus.layer,
Format.defaultLayer,
LSP.defaultLayer,
Truncate.defaultLayer,
testInstanceStoreLayer,
EventV2Bridge.defaultLayer,
)
const apply = (dir: string, patchText: string) =>
@@ -8,14 +8,20 @@ import { InstanceRuntime } from "../../../src/project/instance-runtime"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import { Permission } from "../../../src/permission"
import { PermissionID } from "../../../src/permission/schema"
import { EventV2Bridge } from "../../../src/event-v2-bridge"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Database } from "@opencode-ai/core/database/database"
import { SessionID } from "../../../src/session/schema"
import { provideTmpdirInstance } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const bus = Bus.layer
const env = Layer.mergeAll(
Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)),
Permission.layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Database.defaultLayer),
),
bus,
CrossSpawnSpawner.defaultLayer,
)
@@ -98,7 +104,7 @@ describe("env read permissions", () => {
Effect.gen(function* () {
const session = SessionID.make("session_env")
const first = yield* ask({
id: PermissionID.make("per_env_first"),
id: PermissionV1.ID.make("per_env_first"),
sessionID: session,
permission: "read",
patterns: ["README.md"],
@@ -108,11 +114,11 @@ describe("env read permissions", () => {
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_env_first"), reply: "always" })
yield* reply({ requestID: PermissionV1.ID.make("per_env_first"), reply: "always" })
yield* Fiber.join(first)
const second = yield* ask({
id: PermissionID.make("per_env_second"),
id: PermissionV1.ID.make("per_env_second"),
sessionID: session,
permission: "read",
patterns: ["project/.env"],
@@ -122,7 +128,7 @@ describe("env read permissions", () => {
}).pipe(Effect.forkScoped)
const items = yield* waitForPending(1)
expect(items[0].id).toBe(PermissionID.make("per_env_second"))
expect(items[0].id).toBe(PermissionV1.ID.make("per_env_second"))
yield* rejectAll()
yield* Fiber.await(second)
@@ -134,7 +140,7 @@ describe("env read permissions", () => {
withDir(() =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("per_env_everything"),
id: PermissionV1.ID.make("per_env_everything"),
sessionID: SessionID.make("session_env"),
permission: "read",
patterns: ["project/.env"],
@@ -144,10 +150,10 @@ describe("env read permissions", () => {
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* allow({ enable: true, requestID: PermissionID.make("per_env_everything") })
yield* allow({ enable: true, requestID: PermissionV1.ID.make("per_env_everything") })
const items = yield* waitForPending(1)
expect(items[0].id).toBe(PermissionID.make("per_env_everything"))
expect(items[0].id).toBe(PermissionV1.ID.make("per_env_everything"))
yield* rejectAll()
yield* Fiber.await(asking)
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer, ManagedRuntime } from "effect"
import path from "path"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import { Agent } from "../../../src/agent/agent"
@@ -9,7 +9,9 @@ import { Bus } from "../../../src/bus"
import { Config } from "../../../src/config/config"
import { RuntimeFlags } from "../../../src/effect/runtime-flags"
import { Permission } from "../../../src/permission"
import { PermissionID } from "../../../src/permission/schema"
import { EventV2Bridge } from "../../../src/event-v2-bridge"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Database } from "@opencode-ai/core/database/database"
import { provideTestInstance } from "../../fixture/fixture"
import { MessageID, SessionID } from "../../../src/session/schema"
import { Shell } from "../../../src/shell/shell"
@@ -23,7 +25,7 @@ import { ConfigProtection } from "../../../src/kilocode/permission/config-paths"
const runtime = ManagedRuntime.make(
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
FSUtil.defaultLayer,
Config.defaultLayer,
RuntimeFlags.layer(),
Plugin.defaultLayer,
@@ -58,10 +60,10 @@ Shell.acceptable.reset()
const init = () => runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init())))
const quote = (text: string) => `"${text.replaceAll('"', '\\"')}"`
const glob = (file: string) =>
process.platform === "win32" ? AppFileSystem.normalizePathPattern(file) : file.replaceAll("\\", "/")
process.platform === "win32" ? FSUtil.normalizePathPattern(file) : file.replaceAll("\\", "/")
const variants = (dir: string) => {
if (process.platform !== "win32") return [dir]
const full = AppFileSystem.normalizePath(dir)
const full = FSUtil.normalizePath(dir)
const slash = full.replaceAll("\\", "/")
const root = slash.replace(/^[A-Za-z]:/, "")
return Array.from(new Set([full, slash, root, root.toLowerCase()]))
@@ -71,7 +73,11 @@ const configFile = path.join(config, "hello.txt")
const configGlob = glob(path.join(config, "*"))
const bus = Bus.layer
const env = Layer.mergeAll(
Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)),
Permission.layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Database.defaultLayer),
),
bus,
CrossSpawnSpawner.defaultLayer,
)
@@ -159,7 +165,7 @@ describe("external_directory allow config protection", () => {
() =>
immediate(
ask({
id: PermissionID.make("permission_file_external_read"),
id: PermissionV1.ID.make("permission_file_external_read"),
sessionID: SessionID.make("session_file_external_read"),
permission: "external_directory",
patterns: [configGlob],
@@ -177,7 +183,7 @@ describe("external_directory allow config protection", () => {
() =>
immediate(
ask({
id: PermissionID.make("permission_bash_external_read"),
id: PermissionV1.ID.make("permission_bash_external_read"),
sessionID: SessionID.make("session_bash_external_read"),
permission: "external_directory",
patterns: [configGlob],
@@ -207,7 +213,7 @@ describe("external_directory allow config protection", () => {
() =>
Effect.gen(function* () {
const pending = yield* ask({
id: PermissionID.make("permission_bash_external_write"),
id: PermissionV1.ID.make("permission_bash_external_write"),
sessionID: SessionID.make("session_bash_external_write"),
permission: "external_directory",
patterns: [configGlob],
@@ -218,12 +224,12 @@ describe("external_directory allow config protection", () => {
const requests = yield* wait(1)
expect(requests[0]).toMatchObject({
id: PermissionID.make("permission_bash_external_write"),
id: PermissionV1.ID.make("permission_bash_external_write"),
permission: "external_directory",
metadata: { disableAlways: true, configProtected: true },
})
yield* reply({ requestID: PermissionID.make("permission_bash_external_write"), reply: "reject" })
yield* reply({ requestID: PermissionV1.ID.make("permission_bash_external_write"), reply: "reject" })
const exit = yield* Fiber.await(pending)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
@@ -4,7 +4,9 @@ import path from "path"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { Bus } from "../../../src/bus"
import { Permission } from "../../../src/permission"
import { PermissionID } from "../../../src/permission/schema"
import { EventV2Bridge } from "../../../src/event-v2-bridge"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Database } from "@opencode-ai/core/database/database"
import { SessionID } from "../../../src/session/schema"
import * as Config from "../../../src/config/config"
import { InstanceRuntime } from "../../../src/project/instance-runtime"
@@ -15,7 +17,11 @@ import { testEffect } from "../../lib/effect"
const bus = Bus.layer
const env = Layer.mergeAll(
Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)),
Permission.layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Database.defaultLayer),
),
Config.defaultLayer,
bus,
CrossSpawnSpawner.defaultLayer,
@@ -84,7 +90,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("permission_1"),
id: PermissionV1.ID.make("permission_1"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["npm install"],
@@ -95,10 +101,10 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(1)
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_1"),
requestID: PermissionV1.ID.make("permission_1"),
approvedAlways: ["npm install"],
})
yield* reply({ requestID: PermissionID.make("permission_1"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_1"), reply: "once" })
yield* Fiber.join(asking)
const result = yield* ask({
@@ -118,7 +124,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("permission_2"),
id: PermissionV1.ID.make("permission_2"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["rm -rf /"],
@@ -129,10 +135,10 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(1)
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_2"),
requestID: PermissionV1.ID.make("permission_2"),
deniedAlways: ["rm -rf /"],
})
yield* reply({ requestID: PermissionID.make("permission_2"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_2"), reply: "once" })
yield* Fiber.join(asking)
const exit = yield* ask({
@@ -152,7 +158,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const exit = yield* saveAlwaysRules({
requestID: PermissionID.make("permission_nonexistent"),
requestID: PermissionV1.ID.make("permission_nonexistent"),
approvedAlways: ["npm install"],
}).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@@ -170,7 +176,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("permission_3"),
id: PermissionV1.ID.make("permission_3"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["npm install"],
@@ -182,11 +188,11 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(1)
// "curl" is not in metadata.rules or always — should be silently ignored
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_3"),
requestID: PermissionV1.ID.make("permission_3"),
approvedAlways: ["npm install", "curl http://evil.com"],
})
yield* reply({ requestID: PermissionID.make("permission_3"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_3"), reply: "once" })
yield* Fiber.join(asking)
// npm install was in rules — auto-allowed
@@ -202,7 +208,7 @@ describe("saveAlwaysRules", () => {
// curl was NOT in rules — still requires permission
const curlFiber = yield* ask({
id: PermissionID.make("permission_curl"),
id: PermissionV1.ID.make("permission_curl"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["curl http://evil.com"],
@@ -212,7 +218,7 @@ describe("saveAlwaysRules", () => {
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("permission_curl"), reply: "reject" })
yield* reply({ requestID: PermissionV1.ID.make("permission_curl"), reply: "reject" })
expectFailure(yield* Fiber.await(curlFiber), Permission.RejectedError)
}),
),
@@ -222,7 +228,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("permission_nonbash"),
id: PermissionV1.ID.make("permission_nonbash"),
sessionID: SessionID.make("session_test"),
permission: "read",
patterns: ["src/main.ts"],
@@ -234,10 +240,10 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(1)
// "*" is in always — should be accepted even without metadata.rules
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_nonbash"),
requestID: PermissionV1.ID.make("permission_nonbash"),
approvedAlways: ["*"],
})
yield* reply({ requestID: PermissionID.make("permission_nonbash"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_nonbash"), reply: "once" })
yield* Fiber.join(asking)
// "*" wildcard should auto-allow any read
@@ -258,7 +264,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("permission_hard_deny_seed"),
id: PermissionV1.ID.make("permission_hard_deny_seed"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["printf seed"],
@@ -268,7 +274,7 @@ describe("saveAlwaysRules", () => {
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("permission_hard_deny_seed"), reply: "always" })
yield* reply({ requestID: PermissionV1.ID.make("permission_hard_deny_seed"), reply: "always" })
yield* Fiber.join(asking)
const exit = yield* ask({
@@ -294,7 +300,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("permission_hard_ask_seed"),
id: PermissionV1.ID.make("permission_hard_ask_seed"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["gh issue list"],
@@ -304,7 +310,7 @@ describe("saveAlwaysRules", () => {
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("permission_hard_ask_seed"), reply: "always" })
yield* reply({ requestID: PermissionV1.ID.make("permission_hard_ask_seed"), reply: "always" })
yield* Fiber.join(asking)
const result = yield* ask({
@@ -355,7 +361,7 @@ describe("saveAlwaysRules", () => {
const root = path.resolve(path.dirname(dir), "legacy")
const glob = path.join(root, "*")
const asking = yield* ask({
id: PermissionID.make("permission_external_seed"),
id: PermissionV1.ID.make("permission_external_seed"),
sessionID: SessionID.make("session_test"),
permission: "external_directory",
patterns: [glob],
@@ -365,7 +371,7 @@ describe("saveAlwaysRules", () => {
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("permission_external_seed"), reply: "always" })
yield* reply({ requestID: PermissionV1.ID.make("permission_external_seed"), reply: "always" })
yield* Fiber.join(asking)
const result = yield* ask({
@@ -415,7 +421,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("permission_4"),
id: PermissionV1.ID.make("permission_4"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["npm install lodash"],
@@ -427,10 +433,10 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(1)
// Approve the broadest hierarchy level
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_4"),
requestID: PermissionV1.ID.make("permission_4"),
approvedAlways: ["npm *"],
})
yield* reply({ requestID: PermissionID.make("permission_4"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_4"), reply: "once" })
yield* Fiber.join(asking)
// "npm *" wildcard should auto-allow any npm command
@@ -451,7 +457,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("permission_5"),
id: PermissionV1.ID.make("permission_5"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["npm install lodash"],
@@ -463,11 +469,11 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(1)
// Deny broad, allow specific — specific should win
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_5"),
requestID: PermissionV1.ID.make("permission_5"),
approvedAlways: ["npm install *"],
deniedAlways: ["npm *"],
})
yield* reply({ requestID: PermissionID.make("permission_5"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_5"), reply: "once" })
yield* Fiber.join(asking)
// "npm install foo" matches both rules; "npm install *" (allow) comes
@@ -489,7 +495,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("permission_6"),
id: PermissionV1.ID.make("permission_6"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["git log --oneline"],
@@ -500,11 +506,11 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(1)
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_6"),
requestID: PermissionV1.ID.make("permission_6"),
approvedAlways: ["git log *"],
deniedAlways: ["git *"],
})
yield* reply({ requestID: PermissionID.make("permission_6"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_6"), reply: "once" })
yield* Fiber.join(asking)
// "git log --oneline" should be allowed (specific allow after broad deny)
@@ -536,7 +542,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("permission_7"),
id: PermissionV1.ID.make("permission_7"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["npm install"],
@@ -548,15 +554,15 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(1)
// "curl" is not in metadata.rules — should be silently ignored
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_7"),
requestID: PermissionV1.ID.make("permission_7"),
approvedAlways: ["npm *", "curl *"],
})
yield* reply({ requestID: PermissionID.make("permission_7"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_7"), reply: "once" })
yield* Fiber.join(asking)
// curl should still require permission (not auto-allowed)
const curlFiber = yield* ask({
id: PermissionID.make("permission_curl2"),
id: PermissionV1.ID.make("permission_curl2"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["curl http://example.com"],
@@ -566,7 +572,7 @@ describe("saveAlwaysRules", () => {
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("permission_curl2"), reply: "reject" })
yield* reply({ requestID: PermissionV1.ID.make("permission_curl2"), reply: "reject" })
expectFailure(yield* Fiber.await(curlFiber), Permission.RejectedError)
}),
),
@@ -576,7 +582,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiberA = yield* ask({
id: PermissionID.make("permission_a"),
id: PermissionV1.ID.make("permission_a"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["npm install"],
@@ -586,7 +592,7 @@ describe("saveAlwaysRules", () => {
}).pipe(Effect.forkScoped)
const fiberB = yield* ask({
id: PermissionID.make("permission_b"),
id: PermissionV1.ID.make("permission_b"),
sessionID: SessionID.make("session_b"),
permission: "bash",
patterns: ["npm test"],
@@ -598,12 +604,12 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(2)
// User approves "npm *" on subagent A's permission
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_a"),
requestID: PermissionV1.ID.make("permission_a"),
approvedAlways: ["npm *"],
})
// Subagent B should auto-resolve because "npm test" matches "npm *"
yield* reply({ requestID: PermissionID.make("permission_a"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_a"), reply: "once" })
yield* Fiber.join(fiberA)
yield* Fiber.join(fiberB)
}),
@@ -614,7 +620,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiberA = yield* ask({
id: PermissionID.make("permission_a2"),
id: PermissionV1.ID.make("permission_a2"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["npm install lodash"],
@@ -624,7 +630,7 @@ describe("saveAlwaysRules", () => {
}).pipe(Effect.forkScoped)
const fiberB = yield* ask({
id: PermissionID.make("permission_b2"),
id: PermissionV1.ID.make("permission_b2"),
sessionID: SessionID.make("session_b"),
permission: "bash",
patterns: ["npm run build"],
@@ -634,7 +640,7 @@ describe("saveAlwaysRules", () => {
}).pipe(Effect.forkScoped)
const fiberC = yield* ask({
id: PermissionID.make("permission_c2"),
id: PermissionV1.ID.make("permission_c2"),
sessionID: SessionID.make("session_c"),
permission: "bash",
patterns: ["npm test"],
@@ -646,10 +652,10 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(3)
// Approve "npm *" on session A — should auto-resolve B and C
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_a2"),
requestID: PermissionV1.ID.make("permission_a2"),
approvedAlways: ["npm *"],
})
yield* reply({ requestID: PermissionID.make("permission_a2"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_a2"), reply: "once" })
yield* Fiber.join(fiberA)
yield* Fiber.join(fiberB)
@@ -662,7 +668,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiberA = yield* ask({
id: PermissionID.make("permission_a3"),
id: PermissionV1.ID.make("permission_a3"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["npm install"],
@@ -672,7 +678,7 @@ describe("saveAlwaysRules", () => {
}).pipe(Effect.forkScoped)
const fiberB = yield* ask({
id: PermissionID.make("permission_b3"),
id: PermissionV1.ID.make("permission_b3"),
sessionID: SessionID.make("session_b"),
permission: "bash",
patterns: ["curl http://example.com"],
@@ -684,14 +690,14 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(2)
// Approve "npm *" — should NOT resolve B (curl doesn't match npm *)
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_a3"),
requestID: PermissionV1.ID.make("permission_a3"),
approvedAlways: ["npm *"],
})
yield* reply({ requestID: PermissionID.make("permission_a3"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_a3"), reply: "once" })
yield* Fiber.join(fiberA)
// B should still be pending — reject it to clean up
yield* reply({ requestID: PermissionID.make("permission_b3"), reply: "reject" })
yield* reply({ requestID: PermissionV1.ID.make("permission_b3"), reply: "reject" })
expectFailure(yield* Fiber.await(fiberB), Permission.RejectedError)
}),
),
@@ -701,7 +707,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiberA = yield* ask({
id: PermissionID.make("permission_a4"),
id: PermissionV1.ID.make("permission_a4"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["npm install"],
@@ -713,7 +719,7 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(1)
// Save rules but don't reply yet — the request itself should not be auto-resolved
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_a4"),
requestID: PermissionV1.ID.make("permission_a4"),
approvedAlways: ["npm *"],
})
@@ -721,7 +727,7 @@ describe("saveAlwaysRules", () => {
const pending = yield* list()
expect(pending.some((p) => String(p.id) === "permission_a4")).toBe(true)
yield* reply({ requestID: PermissionID.make("permission_a4"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_a4"), reply: "once" })
yield* Fiber.join(fiberA)
}),
),
@@ -731,7 +737,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("permission_saved_always"),
id: PermissionV1.ID.make("permission_saved_always"),
sessionID: SessionID.make("session_saved_always"),
permission: "bash",
patterns: ["kilo-permission-8353 test"],
@@ -742,10 +748,10 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(1)
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_saved_always"),
requestID: PermissionV1.ID.make("permission_saved_always"),
approvedAlways: ["kilo-permission-8353 test"],
})
yield* reply({ requestID: PermissionID.make("permission_saved_always"), reply: "always" })
yield* reply({ requestID: PermissionV1.ID.make("permission_saved_always"), reply: "always" })
yield* Fiber.join(fiber)
const config = yield* Config.Service
@@ -754,7 +760,7 @@ describe("saveAlwaysRules", () => {
expect(cfg.permission?.bash).not.toMatchObject({ "kilo-permission-8353 *": "allow" })
const broad = yield* ask({
id: PermissionID.make("permission_saved_always_broad"),
id: PermissionV1.ID.make("permission_saved_always_broad"),
sessionID: SessionID.make("session_saved_always"),
permission: "bash",
patterns: ["kilo-permission-8353 install"],
@@ -764,7 +770,7 @@ describe("saveAlwaysRules", () => {
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("permission_saved_always_broad"), reply: "reject" })
yield* reply({ requestID: PermissionV1.ID.make("permission_saved_always_broad"), reply: "reject" })
expectFailure(yield* Fiber.await(broad), Permission.RejectedError)
}),
),
@@ -774,7 +780,7 @@ describe("saveAlwaysRules", () => {
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiberA = yield* ask({
id: PermissionID.make("permission_a5"),
id: PermissionV1.ID.make("permission_a5"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["git log --oneline -5"],
@@ -784,7 +790,7 @@ describe("saveAlwaysRules", () => {
}).pipe(Effect.forkScoped)
const fiberB = yield* ask({
id: PermissionID.make("permission_b5"),
id: PermissionV1.ID.make("permission_b5"),
sessionID: SessionID.make("session_b"),
permission: "bash",
patterns: ["git log --oneline -10"],
@@ -796,12 +802,12 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(2)
// User denies "git log *" on subagent A
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_a5"),
requestID: PermissionV1.ID.make("permission_a5"),
deniedAlways: ["git log *"],
})
// Subagent B should auto-reject because "git log --oneline -10" matches denied "git log *"
yield* reply({ requestID: PermissionID.make("permission_a5"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_a5"), reply: "once" })
yield* Fiber.join(fiberA)
expectFailure(yield* Fiber.await(fiberB), Permission.RejectedError)
}),
@@ -814,7 +820,7 @@ describe("saveAlwaysRules", () => {
// Subagent B has "git status && npm install" — two patterns.
// Its ruleset already allows "npm install" but "git status" is "ask".
const fiberB = yield* ask({
id: PermissionID.make("permission_multi_b"),
id: PermissionV1.ID.make("permission_multi_b"),
sessionID: SessionID.make("session_b"),
permission: "bash",
patterns: ["git status", "npm install"],
@@ -828,7 +834,7 @@ describe("saveAlwaysRules", () => {
// Subagent A gets "git status" approved
const fiberA = yield* ask({
id: PermissionID.make("permission_multi_a"),
id: PermissionV1.ID.make("permission_multi_a"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["git status"],
@@ -840,10 +846,10 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(2)
// User approves "git *" on subagent A
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_multi_a"),
requestID: PermissionV1.ID.make("permission_multi_a"),
approvedAlways: ["git *"],
})
yield* reply({ requestID: PermissionID.make("permission_multi_a"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_multi_a"), reply: "once" })
// B should auto-resolve: "git status" covered by new rule, "npm install" covered by original ruleset
yield* Fiber.join(fiberA)
@@ -858,7 +864,7 @@ describe("saveAlwaysRules", () => {
// Subagent B has "git status && curl http://evil.com" — two patterns.
// Neither is allowed by the ruleset.
const fiberB = yield* ask({
id: PermissionID.make("permission_multi_b2"),
id: PermissionV1.ID.make("permission_multi_b2"),
sessionID: SessionID.make("session_b"),
permission: "bash",
patterns: ["git status", "curl http://evil.com"],
@@ -868,7 +874,7 @@ describe("saveAlwaysRules", () => {
}).pipe(Effect.forkScoped)
const fiberA = yield* ask({
id: PermissionID.make("permission_multi_a2"),
id: PermissionV1.ID.make("permission_multi_a2"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["git status"],
@@ -880,17 +886,17 @@ describe("saveAlwaysRules", () => {
yield* waitForPending(2)
// User approves "git *" — covers "git status" but NOT "curl"
yield* saveAlwaysRules({
requestID: PermissionID.make("permission_multi_a2"),
requestID: PermissionV1.ID.make("permission_multi_a2"),
approvedAlways: ["git *"],
})
yield* reply({ requestID: PermissionID.make("permission_multi_a2"), reply: "once" })
yield* reply({ requestID: PermissionV1.ID.make("permission_multi_a2"), reply: "once" })
yield* Fiber.join(fiberA)
// B should still be pending (curl not covered)
const pending = yield* list()
expect(pending.some((p) => String(p.id) === "permission_multi_b2")).toBe(true)
yield* reply({ requestID: PermissionID.make("permission_multi_b2"), reply: "reject" })
yield* reply({ requestID: PermissionV1.ID.make("permission_multi_b2"), reply: "reject" })
expectFailure(yield* Fiber.await(fiberB), Permission.RejectedError)
}),
),
@@ -4,20 +4,27 @@ import path from "path"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { Bus } from "../../../src/bus"
import { Permission } from "../../../src/permission"
import { PermissionID } from "../../../src/permission/schema"
import { EventV2Bridge } from "../../../src/event-v2-bridge"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Database } from "@opencode-ai/core/database/database"
import { SessionID } from "../../../src/session/schema"
import * as Config from "../../../src/config/config"
import { InstanceRuntime } from "../../../src/project/instance-runtime"
import { Global } from "@opencode-ai/core/global"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../../fixture/fixture"
import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdirScoped } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const bus = Bus.layer
const env = Layer.mergeAll(
Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)),
Permission.layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Database.defaultLayer),
),
bus,
CrossSpawnSpawner.defaultLayer,
testInstanceStoreLayer,
)
const it = testEffect(env)
@@ -66,7 +73,7 @@ const withProvided =
<A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(provideInstance(dir))
const expectNotFound = (exit: Exit.Exit<void, Permission.NotFoundError>, requestID: PermissionID) => {
const expectNotFound = (exit: Exit.Exit<void, Permission.NotFoundError>, requestID: PermissionV1.ID) => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.squash(exit.cause)).toMatchObject({
@@ -81,7 +88,7 @@ describe("reply routing", () => {
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const requestID = PermissionID.make("permission_unknown")
const requestID = PermissionV1.ID.make("permission_unknown")
const exit = yield* reply({ requestID, reply: "once" }).pipe(Effect.exit)
expectNotFound(exit, requestID)
}),
@@ -94,7 +101,7 @@ describe("reply routing", () => {
() =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("permission_accepted"),
id: PermissionV1.ID.make("permission_accepted"),
sessionID: SessionID.make("session_accept"),
permission: "bash",
patterns: ["ls"],
@@ -105,7 +112,7 @@ describe("reply routing", () => {
yield* waitForPending(1)
yield* reply({
requestID: PermissionID.make("permission_accepted"),
requestID: PermissionV1.ID.make("permission_accepted"),
reply: "once",
})
yield* Fiber.join(asking)
@@ -118,7 +125,7 @@ describe("reply routing", () => {
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const requestID = PermissionID.make("permission_unknown_reject")
const requestID = PermissionV1.ID.make("permission_unknown_reject")
const exit = yield* reply({ requestID, reply: "reject" }).pipe(Effect.exit)
expectNotFound(exit, requestID)
}),
@@ -131,7 +138,7 @@ describe("reply routing", () => {
() =>
Effect.gen(function* () {
const asking = yield* ask({
id: PermissionID.make("permission_double"),
id: PermissionV1.ID.make("permission_double"),
sessionID: SessionID.make("session_double"),
permission: "bash",
patterns: ["echo hi"],
@@ -141,7 +148,7 @@ describe("reply routing", () => {
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
const requestID = PermissionID.make("permission_double")
const requestID = PermissionV1.ID.make("permission_double")
yield* reply({ requestID, reply: "once" })
yield* Fiber.join(asking)
@@ -160,7 +167,7 @@ describe("reply routing", () => {
const runB = withProvided(dirB)
const fiber = yield* ask({
id: PermissionID.make("permission_crossdir"),
id: PermissionV1.ID.make("permission_crossdir"),
sessionID: SessionID.make("session_crossdir"),
permission: "bash",
patterns: ["ls"],
@@ -171,7 +178,7 @@ describe("reply routing", () => {
expect(yield* waitForPending(1).pipe(runA)).toHaveLength(1)
const requestID = PermissionID.make("permission_crossdir")
const requestID = PermissionV1.ID.make("permission_crossdir")
const exit = yield* reply({ requestID, reply: "once" }).pipe(runB, Effect.exit)
expectNotFound(exit, requestID)
@@ -5,7 +5,8 @@ import fs from "fs/promises"
import path from "path"
import { Identifier } from "../../src/id/id"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Instance } from "../../src/kilocode/instance"
import { provideTestInstance } from "../fixture/fixture"
import { PlanFollowup } from "../../src/kilocode/plan-followup"
@@ -20,17 +21,18 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const session = makeRuntime(Session.Service, Session.defaultLayer)
const sessions = {
create: (input?: Parameters<Session.Interface["create"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))),
session.runPromise((svc) => svc.create(input)),
get: (id: SessionID) =>
Effect.runPromise(Session.Service.use((svc) => svc.get(id)).pipe(Effect.provide(Session.defaultLayer))),
session.runPromise((svc) => svc.get(id)),
messages: (input: Parameters<Session.Interface["messages"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))),
session.runPromise((svc) => svc.messages(input)),
updateMessage: <T extends MessageV2.Info>(msg: T) =>
Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))),
session.runPromise((svc) => svc.updateMessage(msg)),
updatePart: <T extends MessageV2.Part>(part: T) =>
Effect.runPromise(Session.Service.use((svc) => svc.updatePart(part)).pipe(Effect.provide(Session.defaultLayer))),
session.runPromise((svc) => svc.updatePart(part)),
}
const runtime = makeRuntime(Question.Service, Question.defaultLayer)
@@ -50,8 +52,8 @@ const questions = {
}
const model = {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
modelID: ModelV2.ID.make("gpt-4"),
}
async function withInstance(fn: () => Promise<void>) {
@@ -7,7 +7,8 @@ import { Instance } from "../../src/kilocode/instance"
import { provideTestInstance } from "../fixture/fixture"
import { Session } from "../../src/session/session"
import { MessageID, PartID } from "../../src/session/schema"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { PlanExitTool } from "../../src/tool/plan"
import { Tool } from "../../src/tool/tool"
import { Truncate } from "../../src/tool/truncate"
@@ -138,7 +139,7 @@ describe("PlanFile", () => {
sessionID: info.id,
time: { created: Date.now() },
agent: "plan",
model: { providerID: ProviderID.make("anthropic"), modelID: ModelID.make("claude-sonnet-5") },
model: { providerID: ProviderV2.ID.make("anthropic"), modelID: ModelV2.ID.make("claude-sonnet-5") },
})
yield* svc.updatePart({
id: PartID.ascending(),
@@ -231,7 +232,7 @@ describe("PlanFile", () => {
sessionID: info.id,
time: { created: Date.now() },
agent: "sr-architect",
model: { providerID: ProviderID.make("anthropic"), modelID: ModelID.make("claude-sonnet-5") },
model: { providerID: ProviderV2.ID.make("anthropic"), modelID: ModelV2.ID.make("claude-sonnet-5") },
})
yield* svc.updatePart({
id: PartID.ascending(),
@@ -293,7 +294,7 @@ describe("PlanFile", () => {
sessionID: info.id,
time: { created: Date.now() },
agent: "code",
model: { providerID: ProviderID.make("anthropic"), modelID: ModelID.make("claude-sonnet-5") },
model: { providerID: ProviderV2.ID.make("anthropic"), modelID: ModelV2.ID.make("claude-sonnet-5") },
})
yield* svc.updatePart({
id: PartID.ascending(),
@@ -4,11 +4,13 @@ import { Telemetry } from "@kilocode/kilo-telemetry"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { GlobalBus } from "../../src/bus/global"
import { TuiEvent } from "../../src/cli/cmd/tui/event"
import { Identifier } from "../../src/id/id"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { EventV2 } from "@opencode-ai/core/event"
import { formatTodos, generateHandover, PlanFollowup, PlanFollowupRuntime } from "../../src/kilocode/plan-followup"
import { Instance } from "../../src/kilocode/instance"
import { Provider } from "../../src/provider/provider"
@@ -26,6 +28,19 @@ import { provideTestInstance, tmpdir } from "../fixture/fixture"
Log.init({ print: false })
process.env.KILO_CLIENT = "cli"
function subscribe<D extends EventV2.Definition>(
definition: D,
callback: (event: { properties: EventV2.Data<D> }) => void,
) {
const directory = Instance.directory
const handler = (event: { directory?: string; payload?: { type?: string; properties?: unknown } }) => {
if (event.directory !== directory || event.payload?.type !== definition.type) return
callback({ properties: event.payload.properties as EventV2.Data<D> })
}
GlobalBus.on("event", handler)
return () => GlobalBus.off("event", handler)
}
const runtime = makeRuntime(Question.Service, Question.defaultLayer)
const question = {
ask(input: Parameters<Question.Interface["ask"]>[0]) {
@@ -51,34 +66,35 @@ const todo = {
},
}
const session = makeRuntime(Session.Service, Session.defaultLayer)
const store = {
create: (input?: Parameters<Session.Interface["create"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))),
session.runPromise((svc) => svc.create(input)),
get: (id: SessionID) =>
Effect.runPromise(Session.Service.use((svc) => svc.get(id)).pipe(Effect.provide(Session.defaultLayer))),
session.runPromise((svc) => svc.get(id)),
messages: (input: Parameters<Session.Interface["messages"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))),
session.runPromise((svc) => svc.messages(input)),
updateMessage: <T extends MessageV2.Info>(msg: T) =>
Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))),
session.runPromise((svc) => svc.updateMessage(msg)),
updatePart: <T extends MessageV2.Part>(part: T) =>
Effect.runPromise(Session.Service.use((svc) => svc.updatePart(part)).pipe(Effect.provide(Session.defaultLayer))),
session.runPromise((svc) => svc.updatePart(part)),
}
const model = {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
modelID: ModelV2.ID.make("gpt-4"),
}
const saved = {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-5"),
providerID: ProviderV2.ID.make("openai"),
modelID: ModelV2.ID.make("gpt-5"),
}
const savedVar = "high"
const config = {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4.1"),
providerID: ProviderV2.ID.make("openai"),
modelID: ModelV2.ID.make("gpt-4.1"),
}
const configVar = "max"
@@ -200,7 +216,7 @@ async function latestUser(sessionID: SessionID) {
}
async function sessions() {
return AppRuntime.runPromise(Session.Service.use((svc) => svc.list()))
return session.runPromise((svc) => svc.list())
}
async function waitQuestion(sessionID: string) {
@@ -613,8 +629,8 @@ describe("plan follow-up", () => {
created: Date.now(),
},
parentID: MessageID.make("msg_parent"),
modelID: ModelID.make("test"),
providerID: ProviderID.make("test"),
modelID: ModelV2.ID.make("test"),
providerID: ProviderV2.ID.make("test"),
mode: "code",
agent: "code",
path: {
@@ -670,7 +686,7 @@ describe("plan follow-up", () => {
const before = await sessions()
const created: SessionID[] = []
const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => {
const unsub = subscribe(TuiEvent.SessionSelect, (event) => {
created.push(event.properties.sessionID)
})
@@ -746,8 +762,8 @@ describe("plan follow-up", () => {
sessionID: SessionID.make("ses_test"),
time: { created: Date.now() },
parentID: MessageID.make("msg_parent"),
modelID: ModelID.make("test"),
providerID: ProviderID.make("test"),
modelID: ModelV2.ID.make("test"),
providerID: ProviderV2.ID.make("test"),
mode: "code",
agent: "code",
path: { cwd: "/tmp", root: "/tmp" },
@@ -885,7 +901,7 @@ describe("plan follow-up", () => {
test("ask - falls back to configured code model when saved CLI code model is unavailable", () =>
withInstance(async () => {
await writeState({ model: { code: { providerID: ProviderID.make("missing"), modelID: ModelID.make("ghost") } } })
await writeState({ model: { code: { providerID: ProviderV2.ID.make("missing"), modelID: ModelV2.ID.make("ghost") } } })
const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => {
if (name === "code") {
return {
@@ -981,8 +997,8 @@ describe("plan follow-up", () => {
sessionID: SessionID.make("ses_test"),
time: { created: Date.now() },
parentID: MessageID.make("msg_parent"),
modelID: ModelID.make("test"),
providerID: ProviderID.make("test"),
modelID: ModelV2.ID.make("test"),
providerID: ProviderV2.ID.make("test"),
mode: "code",
agent: "code",
path: { cwd: "/tmp", root: "/tmp" },
@@ -1005,7 +1021,7 @@ describe("plan follow-up", () => {
}
const seeded = await seed({ text: "1. Add API\n2. Add tests" })
const created: SessionID[] = []
const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => {
const unsub = subscribe(TuiEvent.SessionSelect, (event) => {
created.push(event.properties.sessionID)
})
@@ -1052,8 +1068,8 @@ describe("plan follow-up", () => {
sessionID: SessionID.make("ses_test"),
time: { created: Date.now() },
parentID: MessageID.make("msg_parent"),
modelID: ModelID.make("test"),
providerID: ProviderID.make("test"),
modelID: ModelV2.ID.make("test"),
providerID: ProviderV2.ID.make("test"),
mode: "code",
agent: "code",
path: { cwd: "/tmp", root: "/tmp" },
@@ -1100,7 +1116,7 @@ describe("plan follow-up", () => {
const messages = await store.messages({ sessionID: seeded.sessionID })
const created: SessionID[] = []
const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => {
const unsub = subscribe(TuiEvent.SessionSelect, (event) => {
created.push(event.properties.sessionID)
})
using _bus = {
@@ -1156,7 +1172,7 @@ describe("plan follow-up", () => {
let createdAt: number | undefined
let handoverResolvedAt: number | undefined
const unsub = Bus.subscribe(Session.Event.Created, (event) => {
const unsub = subscribe(Session.Event.Created, (event) => {
// Ignore the seeded planning session; we only care about the follow-up.
if (event.properties.info.id === seeded.sessionID) return
if (createdAt === undefined) createdAt = performance.now()
@@ -1178,8 +1194,8 @@ describe("plan follow-up", () => {
sessionID: SessionID.make("ses_test"),
time: { created: Date.now() },
parentID: MessageID.make("msg_parent"),
modelID: ModelID.make("test"),
providerID: ProviderID.make("test"),
modelID: ModelV2.ID.make("test"),
providerID: ProviderV2.ID.make("test"),
mode: "code",
agent: "code",
path: { cwd: "/tmp", root: "/tmp" },
@@ -1245,7 +1261,7 @@ describe("plan follow-up", () => {
const seeded = await seed({ text: "1. Build" })
let followup: SessionID | undefined
const unsub = Bus.subscribe(Session.Event.Created, (event) => {
const unsub = subscribe(Session.Event.Created, (event) => {
if (event.properties.info.id === seeded.sessionID) return
if (!followup) followup = event.properties.info.id
})
@@ -1261,8 +1277,8 @@ describe("plan follow-up", () => {
sessionID: SessionID.make("ses_test"),
time: { created: Date.now() },
parentID: MessageID.make("msg_parent"),
modelID: ModelID.make("test"),
providerID: ProviderID.make("test"),
modelID: ModelV2.ID.make("test"),
providerID: ProviderV2.ID.make("test"),
mode: "code",
agent: "code",
path: { cwd: "/tmp", root: "/tmp" },
@@ -1343,11 +1359,11 @@ describe("plan follow-up", () => {
let followup: SessionID | undefined
const states: Array<{ sessionID: SessionID; type: string }> = []
const created = Bus.subscribe(Session.Event.Created, (event) => {
const created = subscribe(Session.Event.Created, (event) => {
if (event.properties.info.id === seeded.sessionID) return
if (!followup) followup = event.properties.info.id
})
const status = Bus.subscribe(SessionStatus.Event.Status, (event) => {
const status = subscribe(SessionStatus.Event.Status, (event) => {
states.push({ sessionID: event.properties.sessionID, type: event.properties.status.type })
})
@@ -1362,8 +1378,8 @@ describe("plan follow-up", () => {
sessionID: SessionID.make("ses_test"),
time: { created: Date.now() },
parentID: MessageID.make("msg_parent"),
modelID: ModelID.make("test"),
providerID: ProviderID.make("test"),
modelID: ModelV2.ID.make("test"),
providerID: ProviderV2.ID.make("test"),
mode: "code",
agent: "code",
path: { cwd: "/tmp", root: "/tmp" },

Some files were not shown because too many files have changed in this diff Show More