mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge remote-tracking branch 'origin/investigate-worktree-variant-inheritance' into investigate-worktree-variant-inheritance
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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([]))),
|
||||
)
|
||||
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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,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> = {}) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user