diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 47b8ec99cd..e8efed5233 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -8,8 +8,8 @@ export const KiloPlugin = PluginV2.define({ return { "provider.update": Effect.fn(function* (evt) { if (evt.provider.id !== ProviderV2.ID.make("kilo")) return - evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" - evt.provider.options.headers["X-Title"] = "opencode" + evt.provider.options.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change + evt.provider.options.headers["X-Title"] = "Kilo Code" // kilocode_change }), } }), diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index da1ab282bd..dd656dfc59 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -9,9 +9,9 @@ export const LLMGatewayPlugin = PluginV2.define({ "provider.update": Effect.fn(function* (evt) { if (evt.provider.id !== ProviderV2.ID.make("llmgateway")) return if (evt.provider.enabled === false) return - evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" - evt.provider.options.headers["X-Title"] = "opencode" - evt.provider.options.headers["X-Source"] = "opencode" + evt.provider.options.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change + evt.provider.options.headers["X-Title"] = "Kilo Code" // kilocode_change + evt.provider.options.headers["X-Source"] = "kilo" // kilocode_change }), } }), diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 49ef6af0f6..ebbca16d2a 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -8,9 +8,9 @@ export const NvidiaPlugin = PluginV2.define({ return { "provider.update": Effect.fn(function* (evt) { if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return - evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" - evt.provider.options.headers["X-Title"] = "opencode" - evt.provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode" + evt.provider.options.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change + evt.provider.options.headers["X-Title"] = "Kilo Code" // kilocode_change + evt.provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "KiloCode" // kilocode_change }), } }), diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index 976eea8c05..b3e926137f 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -9,8 +9,8 @@ export const OpenRouterPlugin = PluginV2.define({ return { "provider.update": Effect.fn(function* (evt) { if (evt.provider.id !== ProviderV2.ID.openrouter) return - evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" - evt.provider.options.headers["X-Title"] = "opencode" + evt.provider.options.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change + evt.provider.options.headers["X-Title"] = "Kilo Code" // kilocode_change }), "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 2108542b16..2d50e3b093 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -8,8 +8,8 @@ export const VercelPlugin = PluginV2.define({ return { "provider.update": Effect.fn(function* (evt) { if (evt.provider.id !== ProviderV2.ID.make("vercel")) return - evt.provider.options.headers["http-referer"] = "https://opencode.ai/" - evt.provider.options.headers["x-title"] = "opencode" + evt.provider.options.headers["http-referer"] = "https://kilo.ai/" // kilocode_change + evt.provider.options.headers["x-title"] = "Kilo Code" // kilocode_change }), "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 6bdd426010..be8086f482 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -8,8 +8,8 @@ export const ZenmuxPlugin = PluginV2.define({ return { "provider.update": Effect.fn(function* (evt) { if (evt.provider.id !== ProviderV2.ID.make("zenmux")) return - evt.provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/" - evt.provider.options.headers["X-Title"] ??= "opencode" + evt.provider.options.headers["HTTP-Referer"] ??= "https://kilo.ai/" // kilocode_change + evt.provider.options.headers["X-Title"] ??= "Kilo Code" // kilocode_change }), } }), diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index 8b0c57c7fe..fc341f09c0 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -7,6 +7,7 @@ import * as Global from "../global" import { Schema } from "effect" import { Glob } from "./glob" import { createStream } from "rotating-file-stream" // kilocode_change +import { KILO_RUN_ID } from "./opencode-process" // kilocode_change export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({ identifier: "LogLevel", @@ -59,29 +60,44 @@ let logpath = "" export function file() { return logpath } -let write = (msg: any) => { +const stderr = (msg: any) => { process.stderr.write(msg) return msg.length } +let write = stderr +let stream: ReturnType | undefined // kilocode_change export async function init(options: Options) { if (options.level) level = options.level void cleanup(Global.Path.log) - if (options.print) return + // kilocode_change start - initialize one rotating stream and truncate dev.log once per Kilo run + if (stream) { + const active = stream + stream = undefined + await new Promise((resolve) => active.end(resolve)) + } + if (options.print) { + write = stderr + return + } logpath = path.join( Global.Path.log, options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log", ) - await fs.truncate(logpath).catch(() => {}) - // kilocode_change start - use rotating-file-stream to cap log files at 50 MB + const run = process.env[KILO_RUN_ID] + if (!options.dev || !run || process.env[initializedRunID] !== run) { + await fs.truncate(logpath).catch(() => {}) + if (options.dev && run) process.env[initializedRunID] = run + } const dir = path.dirname(logpath) - const stream = createStream(path.basename(logpath), { + const active = createStream(path.basename(logpath), { size: "50M", maxFiles: 10, history: ".log-history", path: dir, }) - stream.on("rotation", () => { + stream = active + active.on("rotation", () => { if (!existsSync(dir)) return try { @@ -95,14 +111,14 @@ export async function init(options: Options) { process.stderr.write("log stream warning: " + msg + "\n") } }) - stream.on("error", (err: Error) => { + active.on("error", (err: Error) => { process.stderr.write("log stream error: " + err.message + "\n") }) - stream.on("warning", (err: Error) => { + active.on("warning", (err: Error) => { process.stderr.write("log stream warning: " + err.message + "\n") }) write = (msg: any) => { - stream.write(msg) + active.write(msg) return msg.length } // kilocode_change end diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 4261ae1328..d238d5a436 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -32,8 +32,8 @@ describe("KiloPlugin", () => { const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) expect(result.provider.options.headers).toEqual({ Existing: "value", - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", + "HTTP-Referer": "https://kilo.ai/", + "X-Title": "Kilo Code", }) expect(ignored.provider.options.headers).toEqual({}) }), @@ -46,8 +46,8 @@ describe("KiloPlugin", () => { const result = yield* plugin.trigger("provider.update", {}, { provider: provider("kilo"), cancel: false }) expect(result.provider.options.headers).toEqual({ - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", + "HTTP-Referer": "https://kilo.ai/", + "X-Title": "Kilo Code", }) expect(result.provider.options.headers).not.toHaveProperty("http-referer") expect(result.provider.options.headers).not.toHaveProperty("x-title") @@ -81,8 +81,8 @@ describe("KiloPlugin", () => { ) expect(matchingID.provider.options.headers).toEqual({ - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", + "HTTP-Referer": "https://kilo.ai/", + "X-Title": "Kilo Code", }) expect(matchingPackage.provider.options.headers).toEqual({}) }), diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 1ffea96bcb..4c69aee786 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -42,9 +42,9 @@ describe("LLMGatewayPlugin", () => { ) expect(result.provider.options.headers).toEqual({ Existing: "value", - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", - "X-Source": "opencode", + "HTTP-Referer": "https://kilo.ai/", + "X-Title": "Kilo Code", + "X-Source": "kilo", }) expect(ignored.provider.options.headers).toEqual({}) }), diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index 26e7db0bfb..0ce3d370cb 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -32,9 +32,9 @@ describe("NvidiaPlugin", () => { const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) expect(result.provider.options.headers).toEqual({ Existing: "value", - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", - "X-BILLING-INVOKE-ORIGIN": "OpenCode", + "HTTP-Referer": "https://kilo.ai/", + "X-Title": "Kilo Code", + "X-BILLING-INVOKE-ORIGIN": "KiloCode", }) expect(ignored.provider.options.headers).toEqual({}) }), @@ -57,9 +57,9 @@ describe("NvidiaPlugin", () => { ) expect(result.provider.options.headers).toEqual({ - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", - "X-BILLING-INVOKE-ORIGIN": "OpenCode", + "HTTP-Referer": "https://kilo.ai/", + "X-Title": "Kilo Code", + "X-BILLING-INVOKE-ORIGIN": "KiloCode", }) }), ) @@ -84,8 +84,8 @@ describe("NvidiaPlugin", () => { ) expect(result.provider.options.headers).toEqual({ - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", + "HTTP-Referer": "https://kilo.ai/", + "X-Title": "Kilo Code", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin", }) }), diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 3d143ac7f2..91db253aa0 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -32,8 +32,8 @@ describe("OpenRouterPlugin", () => { const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("nvidia"), cancel: false }) expect(result.provider.options.headers).toEqual({ Existing: "value", - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", + "HTTP-Referer": "https://kilo.ai/", + "X-Title": "Kilo Code", }) expect(ignored.provider.options.headers).toEqual({}) }), diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index 3134a7b83c..53045ca47f 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -21,8 +21,8 @@ describe("VercelPlugin", () => { ) expect(result.provider.options.headers).toEqual({ Existing: "1", - "http-referer": "https://opencode.ai/", - "x-title": "opencode", + "http-referer": "https://kilo.ai/", + "x-title": "Kilo Code", }) }), ) diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 2b7730e6c7..be90baee92 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -20,7 +20,7 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service yield* plugin.add(ZenmuxPlugin) const result = yield* plugin.trigger("provider.update", {}, { provider: provider("zenmux"), cancel: false }) - expect(result.provider.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) + expect(result.provider.options.headers).toEqual({ "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code" }) expect(Object.keys(result.provider.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) expect(result.cancel).toBe(false) }), @@ -43,8 +43,8 @@ describe("ZenmuxPlugin", () => { expect(result.provider.options.headers).toEqual({ Existing: "value", - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", + "HTTP-Referer": "https://kilo.ai/", + "X-Title": "Kilo Code", }) }), ) diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 90e6ce625f..21afcdcab9 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -6,6 +6,7 @@ - + - - @@ -132,8 +133,6 @@ - -- - - - diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 638058d01a..95037773b5 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -5,6 +5,7 @@ import type { Session, SessionStatus, Event, + GlobalEvent, TextPartInput, FilePartInput, Config, @@ -158,6 +159,81 @@ const mapAgent = (a: Agent) => ({ const SESSION_SCOPED_PART_EVENTS = new Set(["message.part.updated", "message.part.delta", "message.part.removed"]) const isSessionScopedPartEvent = (type: string) => SESSION_SCOPED_PART_EVENTS.has(type) +type SyncPayload = Extract +type LegacySyncEvent = + | { + id: string + type: "message.updated" + properties: Extract["data"] + } + | { + id: string + type: "message.removed" + properties: Extract["data"] + } + | { + id: string + type: "message.part.updated" + properties: Extract["data"] + } + | { + id: string + type: "message.part.removed" + properties: Extract["data"] + } + | { + id: string + type: "session.created" + properties: Extract["data"] + } + | { + id: string + type: "session.updated" + properties: Extract["data"] + } + | { + id: string + type: "session.deleted" + properties: Extract["data"] + } + +type ProviderEvent = Event | LegacySyncEvent + +function isLegacySyncEvent(event: ProviderEvent): event is LegacySyncEvent { + return ( + event.type === "message.updated" || + event.type === "message.removed" || + event.type === "message.part.updated" || + event.type === "message.part.removed" || + event.type === "session.created" || + event.type === "session.updated" || + event.type === "session.deleted" + ) +} + +function unwrapSyncEvent(event: GlobalEvent["payload"]): ProviderEvent | undefined { + if (event.type !== "sync") return event + + switch (event.name) { + case "message.updated.1": + return { id: event.id, type: "message.updated", properties: event.data } + case "message.removed.1": + return { id: event.id, type: "message.removed", properties: event.data } + case "message.part.updated.1": + return { id: event.id, type: "message.part.updated", properties: event.data } + case "message.part.removed.1": + return { id: event.id, type: "message.part.removed", properties: event.data } + case "session.created.1": + return { id: event.id, type: "session.created", properties: event.data } + case "session.updated.1": + return { id: event.id, type: "session.updated", properties: event.data } + case "session.deleted.1": + return { id: event.id, type: "session.deleted", properties: event.data } + default: + return undefined + } +} + export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider { public static readonly viewType = "kilo-code.SidebarProvider" private readonly instanceId = crypto.randomUUID() @@ -1165,7 +1241,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Subscribe to SSE events for this webview (filtered by tracked sessions) this.unsubscribeEvent = this.connectionService.onEventFiltered( - (event, directory) => { + (payload, directory) => { + const event = unwrapSyncEvent(payload) + if (!event) return false + // Preserve the request origin even when a worktree session is not tracked yet. // Manual replies must target the Instance that owns the pending permission. if (event.type === "permission.asked" && directory) { @@ -1177,7 +1256,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Remote status events are global and should always pass through if (event.type === "kilo-sessions.remote-status-changed") return true - const sessionId = this.connectionService.resolveEventSessionId(event) + const sessionId = this.resolveEventSessionId(event) // message.part.* events are always session-scoped; drop if session unknown. if (!sessionId) return !isSessionScopedPartEvent(event.type) @@ -1194,8 +1273,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return this.trackedSessionIds.has(sessionId) }, - (event, directory) => { - this.handleEvent(event, directory) + (payload, directory) => { + const event = unwrapSyncEvent(payload) + if (event) this.handleEvent(event, directory) }, ) @@ -2868,11 +2948,76 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ]) } + private mapSyncEventToWebviewMessage(event: LegacySyncEvent) { + switch (event.type) { + case "message.updated": { + const info = event.properties.info + return { + type: "messageCreated" as const, + message: { + ...info, + createdAt: new Date(info.time.created).toISOString(), + }, + } + } + case "message.removed": + return { + type: "messageRemoved" as const, + sessionID: event.properties.sessionID, + messageID: event.properties.messageID, + } + case "message.part.updated": + return { + type: "partUpdated" as const, + sessionID: event.properties.sessionID, + messageID: event.properties.part.messageID, + part: event.properties.part, + } + case "message.part.removed": + return { + type: "partRemoved" as const, + sessionID: event.properties.sessionID, + messageID: event.properties.messageID, + partID: event.properties.partID, + } + case "session.created": + return { + type: "sessionCreated" as const, + session: this.sessionToWebview(event.properties.info), + } + case "session.updated": + return { + type: "sessionUpdated" as const, + session: { ...event.properties.info, id: event.properties.sessionID }, + } + case "session.deleted": + return null + } + } + + private resolveEventSessionId(event: ProviderEvent): string | undefined { + switch (event.type) { + case "session.created": + case "session.updated": + case "session.deleted": + return event.properties.sessionID + case "message.updated": + this.connectionService.recordMessageSessionId(event.properties.info.id, event.properties.sessionID) + return event.properties.sessionID + case "message.removed": + case "message.part.updated": + case "message.part.removed": + return event.properties.sessionID + default: + return this.connectionService.resolveEventSessionId(event) + } + } + /** * Handle SSE events from the CLI backend. * Filters events by project ID and tracked session IDs so each webview only sees its own sessions. */ - private handleEvent(event: Event, directory?: string): void { + private handleEvent(event: ProviderEvent, directory?: string): void { if (event.type === "kilo-sessions.remote-status-changed") { this.remoteService?.updateFromEvent({ enabled: event.properties.enabled, connected: event.properties.connected }) return @@ -2881,7 +3026,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Drop session events from other projects before any tracking logic. // This must come first: the trackedSessionIds guard below would otherwise // let a foreign session through if it was accidentally tracked. - if (isEventFromForeignProject(event, this.projectID)) return + if (!isLegacySyncEvent(event) && isEventFromForeignProject(event, this.projectID)) return + if ( + this.projectID && + (event.type === "session.created" || event.type === "session.updated") && + event.properties.info.projectID !== this.projectID + ) { + return + } if (event.type === "mcp.browser.open.failed") { McpOAuth.openMcpOAuthUrlOnce(event.properties.url) @@ -2913,7 +3065,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } - const sessionID = this.connectionService.resolveEventSessionId(event) + const sessionID = this.resolveEventSessionId(event) // Events without sessionID (server.connected, server.heartbeat, indexing.status) → always forward // Events with sessionID → only forward if this webview tracks that session @@ -2952,9 +3104,38 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.contextSessionID = event.properties.info.id this.trackedSessionIds.add(event.properties.info.id) } - if (event.type === "session.updated" && this.currentSession?.id === event.properties.info.id) { - this.setCurrentSession(event.properties.info) - this.contextSessionID = event.properties.info.id + if (event.type === "session.updated" && this.currentSession?.id === event.properties.sessionID) { + const info = event.properties.info + const session: Session = { + ...this.currentSession, + ...(info.slug != null && { slug: info.slug }), + ...(info.projectID != null && { projectID: info.projectID }), + ...(info.workspaceID != null && { workspaceID: info.workspaceID }), + ...(info.directory != null && { directory: info.directory }), + ...(info.path != null && { path: info.path }), + ...(info.parentID != null && { parentID: info.parentID }), + ...(info.summary != null && { summary: info.summary }), + ...(info.cost != null && { cost: info.cost }), + ...(info.tokens != null && { tokens: info.tokens }), + ...(info.share != null && info.share.url != null && { share: { url: info.share.url } }), + ...(info.title != null && { title: info.title }), + ...(info.agent != null && { agent: info.agent }), + ...(info.model != null && { model: info.model }), + ...(info.version != null && { version: info.version }), + ...(info.time != null && { + time: { + ...this.currentSession.time, + ...(info.time.created != null && { created: info.time.created }), + ...(info.time.updated != null && { updated: info.time.updated }), + ...(info.time.compacting != null && { compacting: info.time.compacting }), + ...(info.time.archived != null && { archived: info.time.archived }), + }, + }), + ...(info.permission != null && { permission: info.permission }), + ...(info.revert != null && { revert: info.revert }), + } + this.setCurrentSession(session) + this.contextSessionID = event.properties.sessionID } // Auto-adopt child sessions as soon as the task tool part reveals their ID. @@ -2976,13 +3157,27 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - handleNetworkEvent(event.type as string, event.properties as any, this.client, (s) => this.getWorkspaceDirectory(s)) + if (!isLegacySyncEvent(event)) { + const props = event.properties + handleNetworkEvent( + event.type, + { + id: "id" in props && typeof props.id === "string" ? props.id : undefined, + sessionID: "sessionID" in props && typeof props.sessionID === "string" ? props.sessionID : undefined, + requestID: "requestID" in props && typeof props.requestID === "string" ? props.requestID : undefined, + }, + this.client, + (s) => this.getWorkspaceDirectory(s), + ) + } if (event.type === "indexing.status" && directory) { if (!sameDirectory(directory, this.getWorkspaceDirectory(this.currentSession?.id))) return } - const msg = mapSSEEventToWebviewMessage(event, sessionID) + const msg = isLegacySyncEvent(event) + ? this.mapSyncEventToWebviewMessage(event) + : mapSSEEventToWebviewMessage(event, sessionID) if (!msg) return if (msg.type === "partUpdated") { this.streams.push({ ...msg, part: this.slimPart(msg.part) }) diff --git a/packages/kilo-vscode/src/MarketplacePanelProvider.ts b/packages/kilo-vscode/src/MarketplacePanelProvider.ts index c38ea033f2..cbdd5f53de 100644 --- a/packages/kilo-vscode/src/MarketplacePanelProvider.ts +++ b/packages/kilo-vscode/src/MarketplacePanelProvider.ts @@ -1,6 +1,6 @@ import * as os from "os" import * as vscode from "vscode" -import type { Event, SessionStatus } from "@kilocode/sdk/v2/client" +import type { GlobalEvent, SessionStatus } from "@kilocode/sdk/v2/client" import { buildWebviewHtml, getWebviewFontSize } from "./utils" import { watchFontSizeConfig } from "./kilo-provider/font-size" import { mapSSEEventToWebviewMessage } from "./kilo-provider-utils" @@ -114,7 +114,9 @@ export class MarketplacePanelProvider implements vscode.Disposable { this.connection.onLanguageChanged((locale) => this.post({ type: "languageChanged", locale })), this.connection.onEventFiltered( (event) => event.type === "session.status", - (event) => this.handleStatus(event), + (event) => { + if (event.type === "session.status") this.handleStatus(event) + }, ), ) void this.connect() @@ -236,8 +238,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { this.post({ type: "marketplaceRemoveResult", ...result }) } - private handleStatus(event: Event): void { - if (event.type !== "session.status") return + private handleStatus(event: Extract): void { const sid = event.properties.sessionID this.statuses.set(sid, event.properties.status.type) const msg = mapSSEEventToWebviewMessage(event, sid) diff --git a/packages/kilo-vscode/src/commands/toggle-auto-approve.ts b/packages/kilo-vscode/src/commands/toggle-auto-approve.ts index 29e6ef891c..0e88331ae7 100644 --- a/packages/kilo-vscode/src/commands/toggle-auto-approve.ts +++ b/packages/kilo-vscode/src/commands/toggle-auto-approve.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode" -import type { KiloClient, Event } from "@kilocode/sdk/v2/client" +import type { KiloClient } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../services/cli-backend/connection-service" /** @@ -83,7 +83,7 @@ export function registerToggleAutoApprove( return active } - const unsubscribe = connectionService.onEvent((event: Event, directory?: string) => { + const unsubscribe = connectionService.onEvent((event, directory) => { if (!active) return if (event.type !== "permission.asked") return const client = tryGetClient(connectionService) diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index c8ceef403b..d31963261f 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -1,4 +1,16 @@ -import type { Session, Agent, Event, ProviderListResponse } from "@kilocode/sdk/v2/client" +import type { + Session, + Agent, + Event, + ProviderListResponse, + SyncEventMessageUpdated, + SyncEventMessageRemoved, + SyncEventMessagePartUpdated, + SyncEventMessagePartRemoved, + SyncEventSessionCreated, + SyncEventSessionUpdated, + SyncEventSessionDeleted, +} from "@kilocode/sdk/v2/client" import { prettifyError } from "zod/v4" import type { CloudSessionMessage, IndexingStatus } from "./services/cli-backend/types" import type { PartBatch, PartUpdate } from "./kilo-provider/session-stream-scheduler" @@ -375,6 +387,17 @@ export function sameDirectory(a: string, b: string): boolean { return path.relative(left.toLowerCase(), right.toLowerCase()) === "" } +type SyncEvent = + | SyncEventMessageUpdated + | SyncEventMessageRemoved + | SyncEventMessagePartUpdated + | SyncEventMessagePartRemoved + | SyncEventSessionCreated + | SyncEventSessionUpdated + | SyncEventSessionDeleted + +type StreamEvent = Event | SyncEvent + export type WebviewMessage = | PartUpdate | PartBatch @@ -425,83 +448,91 @@ export type WebviewMessage = | { type: "permissionError"; permissionID: string; stale?: boolean } | { type: "sessionCreated"; session: ReturnType; draftID?: string } | { type: "sessionUpdated"; session: ReturnType } + | { type: "sessionDeleted"; sessionID: string } | { type: "messageRemoved"; sessionID: string; messageID: string } | { type: "sessionError"; sessionID?: string; error?: unknown } | null -type PartEvent = Extract +type PartEvent = + | Extract + | SyncEventMessagePartUpdated + | SyncEventMessagePartRemoved function mapPartEvent(event: PartEvent, sessionID: string | undefined): WebviewMessage { + if (event.type === "sync") { + if (event.name === "message.part.updated.1") { + const part = event.data.part + return { + type: "partUpdated", + sessionID: event.data.sessionID, + messageID: part.messageID, + part, + } + } + return { + type: "partRemoved", + sessionID: event.data.sessionID, + messageID: event.data.messageID, + partID: event.data.partID, + } + } if (!sessionID) return null - if (event.type === "message.part.updated") { - const part = event.properties.part as { messageID?: string; sessionID?: string } - return { - type: "partUpdated", - sessionID, - messageID: part.messageID || "", - part: event.properties.part, - } - } - if (event.type === "message.part.delta") { - const props = event.properties - return { - type: "partUpdated", - sessionID: props.sessionID, - messageID: props.messageID, - part: { id: props.partID, type: "text", messageID: props.messageID, text: props.delta }, - delta: { type: "text-delta", textDelta: props.delta }, - } - } const props = event.properties return { - type: "partRemoved", + type: "partUpdated", sessionID: props.sessionID, messageID: props.messageID, - partID: props.partID, + part: { id: props.partID, type: "text", messageID: props.messageID, text: props.delta }, + delta: { type: "text-delta", textDelta: props.delta }, } } -export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | undefined): WebviewMessage { - if ( - event.type === "message.part.updated" || - event.type === "message.part.delta" || - event.type === "message.part.removed" - ) { - return mapPartEvent(event, sessionID) +export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: string | undefined): WebviewMessage { + if (event.type === "sync") { + switch (event.name) { + case "message.updated.1": { + const info = event.data.info + return { + type: "messageCreated", + message: { + ...info, + createdAt: new Date(info.time.created).toISOString(), + }, + } + } + case "message.removed.1": + return { + type: "messageRemoved", + sessionID: event.data.sessionID, + messageID: event.data.messageID, + } + case "message.part.updated.1": + case "message.part.removed.1": + return mapPartEvent(event, sessionID) + case "session.created.1": + return { + type: "sessionCreated", + session: sessionToWebview(event.data.info), + } + case "session.updated.1": + return null + case "session.deleted.1": + return { + type: "sessionDeleted", + sessionID: event.data.sessionID, + } + } } + if (event.type === "message.part.delta") return mapPartEvent(event, sessionID) switch (event.type) { - case "message.updated": { - const info = event.properties.info - return { - type: "messageCreated", - message: { - ...info, - createdAt: new Date(info.time.created).toISOString(), - }, - } - } - case "message.removed": { - const props = event.properties as { sessionID: string; messageID: string } - return { - type: "messageRemoved", - sessionID: props.sessionID, - messageID: props.messageID, - } - } case "session.status": { const info = event.properties.status - // "offline" is not yet in the SDK SessionStatus type (pending SDK regeneration), - // so we use string comparison to forward the message field for offline status. - const status = info.type as string + const status = info.type const extra = - status === "retry" - ? { - attempt: (info as any).attempt as number, - message: (info as any).message as string, - next: (info as any).next as number, - } - : status === "offline" - ? { message: (info as any).message as string } + info.type === "retry" + ? { attempt: info.attempt, message: info.message, next: info.next } + : info.type === "offline" + ? { message: info.message } : {} return { type: "sessionStatus" as const, @@ -583,16 +614,6 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un error: event.properties.error, } } - case "session.created": - return { - type: "sessionCreated", - session: sessionToWebview(event.properties.info), - } - case "session.updated": - return { - type: "sessionUpdated", - session: sessionToWebview(event.properties.info), - } case "indexing.status": return { type: "indexingStatusLoaded", @@ -623,10 +644,12 @@ export function mapCloudSessionMessageToWebviewMessage(message: CloudSessionMess * Returns true when the event carries a projectID that does not match the expected one. * When expectedProjectID is undefined (not yet resolved), nothing is filtered. */ -export function isEventFromForeignProject(event: Event, expectedProjectID: string | undefined): boolean { - if (!expectedProjectID) return false - if (event.type === "session.created" || event.type === "session.updated") { - return event.properties.info.projectID !== expectedProjectID +export function isEventFromForeignProject(event: StreamEvent, expectedProjectID: string | undefined): boolean { + if (!expectedProjectID || event.type !== "sync") return false + if (event.name === "session.created.1" || event.name === "session.deleted.1") { + return event.data.info.projectID !== expectedProjectID } - return false + if (event.name !== "session.updated.1") return false + const project = event.data.info.projectID + return project != null && project !== expectedProjectID } diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index 82fcd1b8e5..8c7d36f3a8 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -1,14 +1,14 @@ import * as vscode from "vscode" import { ServerManager } from "./server-manager" -import { createKiloClient, type KiloClient, type Event } from "@kilocode/sdk/v2/client" -import { SdkSSEAdapter } from "./sdk-sse-adapter" +import { createKiloClient, type KiloClient } from "@kilocode/sdk/v2/client" +import { SdkSSEAdapter, type SSEPayload } from "./sdk-sse-adapter" import type { ServerConfig } from "./types" import { resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" -type SSEEventListener = (event: Event, directory?: string) => void +type SSEEventListener = (event: SSEPayload, directory?: string) => void type StateListener = (state: ConnectionState, error?: Error) => void -type SSEEventFilter = (event: Event, directory?: string) => boolean +type SSEEventFilter = (event: SSEPayload, directory?: string) => boolean type NotificationDismissListener = (notificationId: string) => void type LanguageChangeListener = (locale: string) => void type ProfileChangeListener = (data: unknown) => void @@ -234,7 +234,7 @@ export class KiloConnectionService { * Best-effort sessionID extraction for an SSE event. * Returns undefined for global events. */ - resolveEventSessionId(event: Event): string | undefined { + resolveEventSessionId(event: SSEPayload): string | undefined { return resolveEventSessionIdPure( event, (messageId) => this.messageSessionIdsByMessageId.get(messageId), diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts index 1b0a1dcfa6..308beb15bc 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -1,66 +1,61 @@ -import type { Event } from "@kilocode/sdk/v2/client" +import type { GlobalEvent } from "@kilocode/sdk/v2/client" + +export type SSEPayload = GlobalEvent["payload"] +type SyncPayload = Extract +type TransientPayload = Exclude /** * Pure session ID resolution for SSE events. - * The lookupMessageSessionId callback is used for message.part.updated fallback lookup, - * and onMessageUpdated is called when message.updated is encountered so the caller can - * record the messageID -> sessionID mapping. + * The lookupMessageSessionId callback remains part of the public resolver contract for + * transient events that may only carry a message ID, and onMessageUpdated records the + * messageID -> sessionID mapping from versioned message updates. */ export function resolveEventSessionId( - event: Event, + event: SSEPayload, lookupMessageSessionId: (messageId: string) => string | undefined, onMessageUpdated?: (messageId: string, sessionId: string) => void, ): string | undefined { + if (event.type === "sync") { + return resolveSyncSessionId(event, onMessageUpdated) + } + + void lookupMessageSessionId + return resolveTransientSessionId(event) +} + +function resolveSyncSessionId( + event: SyncPayload, + onMessageUpdated?: (messageId: string, sessionId: string) => void, +): string | undefined { + if (event.name === "message.updated.1") { + onMessageUpdated?.(event.data.info.id, event.data.sessionID) + } + return event.data.sessionID +} + +function resolveTransientSessionId(event: TransientPayload): string | undefined { switch (event.type) { - case "session.created": - case "session.updated": - return event.properties.info.id case "session.status": + case "session.turn.open": case "session.turn.close": case "session.idle": case "session.error": case "todo.updated": - return event.properties.sessionID - case "message.updated": - onMessageUpdated?.(event.properties.info.id, event.properties.info.sessionID) - return event.properties.info.sessionID - case "message.part.updated": { - const part = event.properties.part as { messageID?: string; sessionID?: string } - if (part.sessionID) { - return part.sessionID - } - if (!part.messageID) { - return undefined - } - return lookupMessageSessionId(part.messageID) - } case "message.part.delta": - return event.properties.sessionID - case "message.part.removed": - return event.properties.sessionID case "permission.asked": case "permission.replied": case "question.asked": case "question.replied": case "question.rejected": - return event.properties.sessionID - default: - return resolveSuggestionSessionId(event) - } -} - -function resolveSuggestionSessionId(event: Event): string | undefined { - switch (event.type) { case "suggestion.shown": case "suggestion.accepted": case "suggestion.dismissed": + case "session.network.asked": + case "session.network.replied": + case "session.network.rejected": + case "session.network.restored": return event.properties.sessionID default: - // session.network.* events are not yet in the SDK Event type union - // (pending SDK regeneration). Handle them via string comparison. - if ((event.type as string).startsWith("session.network.")) { - return (event.properties as { sessionID: string }).sessionID - } return undefined } } diff --git a/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts b/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts index f2ba427c83..fc1df70654 100644 --- a/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts +++ b/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts @@ -1,6 +1,7 @@ -import type { KiloClient, GlobalEvent, Event } from "@kilocode/sdk/v2/client" +import type { KiloClient, GlobalEvent } from "@kilocode/sdk/v2/client" -export type SSEEventHandler = (event: Event, directory?: string) => void +export type SSEPayload = GlobalEvent["payload"] +export type SSEEventHandler = (event: SSEPayload, directory?: string) => void export type SSEErrorHandler = (error: Error) => void export type SSEStateHandler = (state: "connecting" | "connected" | "disconnected") => void @@ -179,13 +180,8 @@ export class SdkSSEAdapter { this.notifyState("connected") } - // The SDK yields GlobalEvent = { directory, payload: Event }. - const globalEvent = event as GlobalEvent - const type = (globalEvent.payload as { type: string }).type - if (type !== "server.heartbeat") { - console.log("[Kilo New] SSE: 📨 Event:", type) - } - this.notifyEvent(globalEvent.payload as Event, globalEvent.directory) + console.log("[Kilo New] SSE: 📨 Event:", event.payload.type) + this.notifyEvent(event.payload, event.directory) } console.log( @@ -241,7 +237,7 @@ export class SdkSSEAdapter { // ── Notify helpers ───────────────────────────────────────────────── - private notifyEvent(event: Event, directory?: string): void { + private notifyEvent(event: SSEPayload, directory?: string): void { for (const handler of this.handlers) { try { handler(event, directory) diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts index 4a29c06883..46d74e8a23 100644 --- a/packages/kilo-vscode/tests/unit/connection-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -1,193 +1,166 @@ -import { describe, it, expect } from "bun:test" +import { describe, expect, it } from "bun:test" +import type { GlobalEvent } from "@kilocode/sdk/v2/client" import { resolveEventSessionId } from "../../src/services/cli-backend/connection-utils" -import type { Event } from "@kilocode/sdk/v2/client" const noLookup = (_: string) => undefined -/** Helper to create a partial Event for testing — only the fields accessed by resolveEventSessionId matter. */ -function event(partial: Record): Event { - return partial as unknown as Event +type Payload = GlobalEvent["payload"] + +const message = { + id: "m1", + sessionID: "s5", + role: "user", + time: { created: 0 }, + agent: "build", + model: { providerID: "kilo", modelID: "test" }, +} as const + +const part = { + id: "p1", + sessionID: "s6", + messageID: "m1", + type: "text", + text: "", +} as const + +function sync(event: Extract): Payload { + return event } describe("resolveEventSessionId", () => { - it("returns session id from session.created", () => { - const e = event({ - type: "session.created", - properties: { - info: { id: "s1", title: "", directory: "", time: { created: 0, updated: 0 } }, + it("returns the session ID from session.created.1", () => { + const event = sync({ + type: "sync", + name: "session.created.1", + id: "e1", + seq: 0, + aggregateID: "sessionID", + data: { + sessionID: "s1", + info: { + id: "s1", + slug: "session", + projectID: "project", + directory: "/workspace", + title: "Session", + version: "1", + time: { created: 0, updated: 0 }, + }, }, }) - expect(resolveEventSessionId(e, noLookup)).toBe("s1") + + expect(resolveEventSessionId(event, noLookup)).toBe("s1") }) - it("returns session id from session.updated", () => { - const e = event({ - type: "session.updated", - properties: { - info: { id: "s2", title: "", directory: "", time: { created: 0, updated: 0 } }, - }, + it("returns the session ID from session.updated.1", () => { + const event = sync({ + type: "sync", + name: "session.updated.1", + id: "e2", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: "s2", info: { title: "Updated" } }, }) - expect(resolveEventSessionId(e, noLookup)).toBe("s2") + + expect(resolveEventSessionId(event, noLookup)).toBe("s2") }) - it("returns sessionID from session.status", () => { - const e = event({ - type: "session.status", - properties: { sessionID: "s3", status: { type: "idle" } }, + it("records message.updated.1 mappings", () => { + const event = sync({ + type: "sync", + name: "message.updated.1", + id: "e3", + seq: 2, + aggregateID: "sessionID", + data: { sessionID: "s5", info: message }, }) - expect(resolveEventSessionId(e, noLookup)).toBe("s3") - }) + const recorded: Array<[string, string]> = [] - it("returns sessionID from todo.updated", () => { - const e = event({ - type: "todo.updated", - properties: { sessionID: "s4", todos: [] }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s4") - }) - - it("returns sessionID from session.turn.close", () => { - const e = event({ - type: "session.turn.close", - properties: { sessionID: "s-turn", reason: "interrupted" }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s-turn") - }) - - it("returns sessionID from message.updated and calls onMessageUpdated", () => { - const e = event({ - type: "message.updated", - properties: { - info: { id: "m1", sessionID: "s5", role: "assistant", time: { created: 0 } }, - }, - }) - const recorded: [string, string][] = [] - const result = resolveEventSessionId(e, noLookup, (mid, sid) => recorded.push([mid, sid])) - expect(result).toBe("s5") + expect(resolveEventSessionId(event, noLookup, (mid, sid) => recorded.push([mid, sid]))).toBe("s5") expect(recorded).toEqual([["m1", "s5"]]) }) - it("message.updated does not require onMessageUpdated callback", () => { - const e = event({ - type: "message.updated", - properties: { - info: { id: "m1", sessionID: "s5", role: "assistant", time: { created: 0 } }, - }, + it("does not require a message mapping callback", () => { + const event = sync({ + type: "sync", + name: "message.updated.1", + id: "e4", + seq: 3, + aggregateID: "sessionID", + data: { sessionID: "s5", info: message }, }) - expect(() => resolveEventSessionId(e, noLookup)).not.toThrow() + + expect(() => resolveEventSessionId(event, noLookup)).not.toThrow() }) - it("returns sessionID directly from message.part.updated when part has sessionID", () => { - const e = event({ - type: "message.part.updated", - properties: { - part: { type: "text", id: "p1", text: "", sessionID: "s6", messageID: "m1" }, - }, + it("returns the envelope session ID from message.part.updated.1", () => { + const event = sync({ + type: "sync", + name: "message.part.updated.1", + id: "e5", + seq: 4, + aggregateID: "sessionID", + data: { sessionID: "s6", part, time: 0 }, }) - expect(resolveEventSessionId(e, noLookup)).toBe("s6") + + expect(resolveEventSessionId(event, noLookup)).toBe("s6") }) - it("falls back to lookup when message.part.updated has no sessionID but has messageID", () => { - const e = event({ - type: "message.part.updated", - properties: { - part: { type: "text", id: "p1", text: "", messageID: "m2" }, - }, - }) - const lookup = (id: string) => (id === "m2" ? "s7" : undefined) - expect(resolveEventSessionId(e, lookup)).toBe("s7") + it("routes transient session events", () => { + const event = { + id: "e6", + type: "session.status", + properties: { sessionID: "s3", status: { type: "idle" } }, + } satisfies Payload + + expect(resolveEventSessionId(event, noLookup)).toBe("s3") }) - it("returns undefined for message.part.updated with no sessionID and messageID not in map", () => { - const e = event({ - type: "message.part.updated", - properties: { - part: { type: "text", id: "p1", text: "", messageID: "unknown" }, - }, - }) - expect(resolveEventSessionId(e, noLookup)).toBeUndefined() + it("routes transient message deltas", () => { + const event = { + id: "e7", + type: "message.part.delta", + properties: { sessionID: "s4", messageID: "m2", partID: "p2", field: "text", delta: "x" }, + } satisfies Payload + + expect(resolveEventSessionId(event, noLookup)).toBe("s4") }) - it("returns undefined for message.part.updated with no messageID and no sessionID", () => { - const e = event({ - type: "message.part.updated", - properties: { - part: { type: "text", id: "p1", text: "" }, - }, - }) - expect(resolveEventSessionId(e, noLookup)).toBeUndefined() + it("routes session.network events", () => { + const event = { + id: "e8", + type: "session.network.restored", + properties: { sessionID: "s7" }, + } satisfies Payload + + expect(resolveEventSessionId(event, noLookup)).toBe("s7") }) - it("returns sessionID from permission.asked", () => { - const e = event({ - type: "permission.asked", - properties: { - id: "p1", - sessionID: "s8", - permission: "read_file", - patterns: [], - metadata: {}, - always: [], - }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s8") - }) - - it("returns sessionID from question.asked", () => { - const e = event({ - type: "question.asked", - properties: { id: "q1", sessionID: "s9", questions: [] }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s9") - }) - - it("returns sessionID from question.replied", () => { - const e = event({ - type: "question.replied", - properties: { sessionID: "s10", requestID: "r1", answers: [] }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s10") - }) - - it("returns sessionID from question.rejected", () => { - const e = event({ + it("routes permission, question, and suggestion events", () => { + const permission = { + id: "e9", + type: "permission.replied", + properties: { sessionID: "s8", requestID: "p1", reply: "once" }, + } satisfies Payload + const question = { + id: "e10", type: "question.rejected", - properties: { sessionID: "s11", requestID: "r2" }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s11") - }) - - it("returns sessionID from suggestion.shown", () => { - const e = event({ - type: "suggestion.shown", - properties: { id: "sug_1", sessionID: "s12", text: "Review?", actions: [] }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s12") - }) - - it("returns sessionID from suggestion.accepted", () => { - const e = event({ - type: "suggestion.accepted", - properties: { sessionID: "s13", requestID: "sug_1", index: 0, action: { label: "Start", prompt: "x" } }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s13") - }) - - it("returns sessionID from suggestion.dismissed", () => { - const e = event({ + properties: { sessionID: "s9", requestID: "q1" }, + } satisfies Payload + const suggestion = { + id: "e11", type: "suggestion.dismissed", - properties: { sessionID: "s14", requestID: "sug_2" }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s14") + properties: { sessionID: "s10", requestID: "sg1" }, + } satisfies Payload + + expect(resolveEventSessionId(permission, noLookup)).toBe("s8") + expect(resolveEventSessionId(question, noLookup)).toBe("s9") + expect(resolveEventSessionId(suggestion, noLookup)).toBe("s10") }) - it("returns undefined for unknown event types (global events)", () => { - const e = event({ type: "server.connected", properties: {} }) - expect(resolveEventSessionId(e, noLookup)).toBeUndefined() - }) + it("returns undefined for global events", () => { + const event = { id: "e12", type: "server.connected", properties: {} } satisfies Payload - it("returns undefined for another unknown event type", () => { - const e = event({ type: "server.heartbeat", properties: {} }) - expect(resolveEventSessionId(e, noLookup)).toBeUndefined() + expect(resolveEventSessionId(event, noLookup)).toBeUndefined() }) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index ef4aa2ab74..1280645d13 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -18,8 +18,8 @@ import type { Agent, Provider, Event, - EventMessagePartUpdated, - EventMessageUpdated, + SyncEventMessagePartUpdated, + SyncEventMessageUpdated, EventSessionStatus, EventSessionTurnClose, EventPermissionAsked, @@ -31,8 +31,8 @@ import type { EventSuggestionShown, EventSuggestionAccepted, EventSuggestionDismissed, - EventSessionCreated, - EventSessionUpdated, + SyncEventSessionCreated, + SyncEventSessionUpdated, EventServerConnected, TextPart, AssistantMessage, @@ -243,11 +243,17 @@ describe("buildSettingPath", () => { }) describe("mapSSEEventToWebviewMessage", () => { - it("maps message.part.updated to partUpdated", () => { - const event: EventMessagePartUpdated = { - type: "message.part.updated", - properties: { + it("maps message.part.updated.1 to partUpdated", () => { + const event: SyncEventMessagePartUpdated = { + type: "sync", + name: "message.part.updated.1", + id: "evt-part", + seq: 1, + aggregateID: "sessionID", + data: { + sessionID: "sess-1", part: makeTextPart({ text: "hello" }), + time: 1700000000000, }, } const msg = mapSSEEventToWebviewMessage(event, "sess-1") @@ -258,18 +264,33 @@ describe("mapSSEEventToWebviewMessage", () => { } }) - it("returns null for message.part.updated when sessionID is undefined", () => { - const event: EventMessagePartUpdated = { - type: "message.part.updated", - properties: { part: makeTextPart({ text: "" }) }, + it("maps message.part.updated.1 without a tracked sessionID", () => { + const event: SyncEventMessagePartUpdated = { + type: "sync", + name: "message.part.updated.1", + id: "evt-part", + seq: 1, + aggregateID: "sessionID", + data: { + sessionID: "sess-1", + part: makeTextPart({ text: "" }), + time: 1700000000000, + }, } - expect(mapSSEEventToWebviewMessage(event, undefined)).toBeNull() + const msg = mapSSEEventToWebviewMessage(event, undefined) + expect(msg?.type).toBe("partUpdated") + if (msg?.type === "partUpdated") expect(msg.sessionID).toBe("sess-1") }) - it("maps message.updated to messageCreated with ISO date", () => { - const event: EventMessageUpdated = { - type: "message.updated", - properties: { + it("maps message.updated.1 to messageCreated with ISO date", () => { + const event: SyncEventMessageUpdated = { + type: "sync", + name: "message.updated.1", + id: "evt-message", + seq: 1, + aggregateID: "sessionID", + data: { + sessionID: "sess-1", info: makeAssistantMessage({ cost: 0.001 }), }, } @@ -496,10 +517,14 @@ describe("mapSSEEventToWebviewMessage", () => { expect(msg?.type).toBe("suggestionResolved") }) - it("maps session.created to sessionCreated with ISO dates", () => { - const event: EventSessionCreated = { - type: "session.created", - properties: { info: makeSession() }, + it("maps session.created.1 to sessionCreated with ISO dates", () => { + const event: SyncEventSessionCreated = { + type: "sync", + name: "session.created.1", + id: "evt-session", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: "sess-1", info: makeSession() }, } const msg = mapSSEEventToWebviewMessage(event, "sess-1") expect(msg?.type).toBe("sessionCreated") @@ -508,13 +533,16 @@ describe("mapSSEEventToWebviewMessage", () => { } }) - it("maps session.updated to sessionUpdated with ISO dates", () => { - const event: EventSessionUpdated = { - type: "session.updated", - properties: { info: makeSession({ id: "sess-2" }) }, + it("ignores session.updated.1 after backend state reconciliation", () => { + const event: SyncEventSessionUpdated = { + type: "sync", + name: "session.updated.1", + id: "evt-session", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: "sess-2", info: { id: "sess-2", time: { updated: 1700001000000 } } }, } - const msg = mapSSEEventToWebviewMessage(event, "sess-2") - expect(msg?.type).toBe("sessionUpdated") + expect(mapSSEEventToWebviewMessage(event, "sess-2")).toBeNull() }) it("returns null for server.connected (no webview message)", () => { @@ -529,37 +557,46 @@ describe("mapSSEEventToWebviewMessage", () => { }) describe("isEventFromForeignProject", () => { - const session = (projectID: string) => - ({ - id: "s1", - projectID, - title: "test", - directory: "/workspace", - time: { created: 0, updated: 0 }, - }) as unknown as Session + function created(projectID: string): SyncEventSessionCreated { + return { + type: "sync", + name: "session.created.1", + id: "evt-session", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: "sess-1", info: makeSession({ projectID }) }, + } + } - it("drops session.created from a different project", () => { - const event: Event = { type: "session.created", properties: { info: session("project-B") } } - expect(isEventFromForeignProject(event, "project-A")).toBe(true) + function updated(projectID: string): SyncEventSessionUpdated { + return { + type: "sync", + name: "session.updated.1", + id: "evt-session", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: "sess-1", info: { projectID } }, + } + } + + it("drops session.created.1 from a different project", () => { + expect(isEventFromForeignProject(created("project-B"), "project-A")).toBe(true) }) - it("drops session.updated from a different project", () => { - const event: Event = { type: "session.updated", properties: { info: session("project-B") } } - expect(isEventFromForeignProject(event, "project-A")).toBe(true) + it("drops session.updated.1 from a different project", () => { + expect(isEventFromForeignProject(updated("project-B"), "project-A")).toBe(true) }) - it("keeps session.created from the same project", () => { - const event: Event = { type: "session.created", properties: { info: session("project-A") } } - expect(isEventFromForeignProject(event, "project-A")).toBe(false) + it("keeps session.created.1 from the same project", () => { + expect(isEventFromForeignProject(created("project-A"), "project-A")).toBe(false) }) it("keeps all events when expectedProjectID is undefined", () => { - const event: Event = { type: "session.created", properties: { info: session("project-B") } } - expect(isEventFromForeignProject(event, undefined)).toBe(false) + expect(isEventFromForeignProject(created("project-B"), undefined)).toBe(false) }) it("keeps non-session events regardless of project", () => { - const event = { type: "server.heartbeat", properties: {} } as unknown as Event + const event: EventServerConnected = { type: "server.connected", properties: {} } expect(isEventFromForeignProject(event, "project-A")).toBe(false) }) }) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 166d8dc604..683bc7df55 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -50,7 +50,15 @@ import { ConfigMCP } from "@/config/mcp" import { Todo } from "@/session/todo" import { Result, Schema } from "effect" import { LoadAPIKeyError } from "ai" -import type { AssistantMessage, Event, KiloClient, SessionMessageResponse, ToolPart } from "@kilocode/sdk/v2" +import type { + AssistantMessage, + Event, + GlobalEvent, + KiloClient, + SessionMessageResponse, + SyncEventMessagePartUpdated, + ToolPart, +} from "@kilocode/sdk/v2" import { applyPatch } from "diff" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -59,6 +67,12 @@ import { ShellID } from "@/tool/shell/id" type ModeOption = { id: string; name: string; description?: string } type ModelOption = { modelId: string; name: string } +type MessagePartUpdated = { + id: string + type: "message.part.updated" + properties: SyncEventMessagePartUpdated["data"] +} +type AgentEvent = Event | MessagePartUpdated const decodeTodos = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Array(Todo.Info))) const DEFAULT_VARIANT_VALUE = "default" @@ -185,14 +199,21 @@ export class Agent implements ACPAgent { if (this.eventAbort.signal.aborted) return const payload = event?.payload if (!payload) continue - await this.handleEvent(payload as Event).catch((error) => { + await this.handleEvent(payload).catch((error) => { log.error("failed to handle event", { error, type: payload.type }) }) } } } - private async handleEvent(event: Event) { + private async handleEvent(payload: GlobalEvent["payload"]) { + const event: AgentEvent | undefined = (() => { + if (payload.type !== "sync") return payload + if (payload.name !== "message.part.updated.1") return undefined + return { id: payload.id, type: "message.part.updated", properties: payload.data } + })() + if (!event) return + switch (event.type) { case "permission.asked": { const permission = event.properties diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 3fc889a96e..723403ed89 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -24,6 +24,7 @@ import { InstanceState } from "@/effect/instance-state" import { type DeepMutable } from "@opencode-ai/core/schema" import * as KiloAgent from "@/kilocode/agent" // kilocode_change import { RuntimeFlags } from "@/effect/runtime-flags" +import { Reference } from "@/reference/reference" // kilocode_change export const Info = Schema.Struct({ name: Schema.String, @@ -251,10 +252,10 @@ export const layer = Layer.effect( prompt: PROMPT_COMPACTION, permission: Permission.merge( defaults, + user, Permission.fromConfig({ "*": "deny", }), - user, ), options: {}, }, @@ -267,10 +268,10 @@ export const layer = Layer.effect( temperature: 0.5, permission: Permission.merge( defaults, + user, Permission.fromConfig({ "*": "deny", }), - user, ), prompt: PROMPT_TITLE, }, @@ -282,10 +283,10 @@ export const layer = Layer.effect( hidden: true, permission: Permission.merge( defaults, + user, Permission.fromConfig({ "*": "deny", }), - user, ), prompt: PROMPT_SUMMARY, }, @@ -362,7 +363,7 @@ export const layer = Layer.effect( return `Invalid Scout reference for repository ${reference.repository}` } - if (Flag.KILO_EXPERIMENTAL_SCOUT) { + if (flags.experimentalScout) { const resolvedReferences = Reference.resolveAll({ references: cfg.reference ?? {}, directory: ctx.directory, @@ -442,7 +443,7 @@ export const layer = Layer.effect( } // kilocode_change start - prefer "code" as default agent (key order changes after rename from "build") const code = agents.code - if (code && code.mode !== "subagent" && code.hidden !== true) return code.name + if (code && code.mode !== "subagent" && code.hidden !== true) return code // kilocode_change end const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) if (!visible) throw new Error("no primary visible agent found") @@ -481,7 +482,7 @@ export const layer = Layer.effect( return yield* current((s) => s.list()) // kilocode_change }), defaultInfo: Effect.fn("Agent.defaultInfo")(function* () { - return yield* InstanceState.useEffect(state, (s) => s.defaultInfo()) + return yield* current((s) => s.defaultInfo()) // kilocode_change }), defaultAgent: Effect.fn("Agent.defaultAgent")(function* () { return yield* current((s) => s.defaultAgent()) // kilocode_change diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 7d0af45246..eb744e7362 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -29,6 +29,7 @@ import { Permission } from "@/permission" import { RuntimeFlags } from "@/effect/runtime-flags" import { FormatError, FormatUnknownError } from "../error" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" +import { event as normalizeEvent } from "./run/event" import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change import { KiloRunDaemon } from "@/kilocode/cli/cmd/run" // kilocode_change @@ -668,7 +669,10 @@ export const RunCommand = effectCmd({ let retries = 0 // kilocode_change let error: string | undefined - for await (const event of events.stream) { + for await (const payload of events.stream) { + const event = normalizeEvent(payload) + if (!event) continue + if ( event.type === "message.updated" && event.properties.sessionID === sessionID && diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts index a42f00f87d..3afb4d5812 100644 --- a/packages/opencode/src/cli/cmd/run/demo.ts +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -15,7 +15,8 @@ // Demo mode also handles permission and question replies locally, completing // or failing the synthetic tool parts as appropriate. import path from "path" -import type { Event, ToolPart } from "@kilocode/sdk/v2" +import type { ToolPart } from "@kilocode/sdk/v2" +import type { Event } from "./event" import { createSessionData, reduceSessionData, type SessionData } from "./session-data" import { writeSessionOutput } from "./stream" import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types" diff --git a/packages/opencode/src/cli/cmd/run/event.ts b/packages/opencode/src/cli/cmd/run/event.ts new file mode 100644 index 0000000000..7ab90ef6ff --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/event.ts @@ -0,0 +1,51 @@ +import type { + Event as SDKEvent, + GlobalEvent, + SyncEventMessagePartRemoved, + SyncEventMessagePartUpdated, + SyncEventMessageRemoved, + SyncEventMessageUpdated, +} from "@kilocode/sdk/v2" + +type MessageUpdated = { + id: string + type: "message.updated" + properties: SyncEventMessageUpdated["data"] +} + +type MessageRemoved = { + id: string + type: "message.removed" + properties: SyncEventMessageRemoved["data"] +} + +type MessagePartUpdated = { + id: string + type: "message.part.updated" + properties: SyncEventMessagePartUpdated["data"] +} + +type MessagePartRemoved = { + id: string + type: "message.part.removed" + properties: SyncEventMessagePartRemoved["data"] +} + +export type Event = SDKEvent | MessageUpdated | MessageRemoved | MessagePartUpdated | MessagePartRemoved + +export function event(payload: GlobalEvent["payload"]): Event | undefined { + if (payload.type !== "sync") return payload + + switch (payload.name) { + case "message.updated.1": + return { id: payload.id, type: "message.updated", properties: payload.data } + case "message.removed.1": + return { id: payload.id, type: "message.removed", properties: payload.data } + case "message.part.updated.1": + return { id: payload.id, type: "message.part.updated", properties: payload.data } + case "message.part.removed.1": + return { id: payload.id, type: "message.part.removed", properties: payload.data } + default: + return undefined + } +} diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/opencode/src/cli/cmd/run/session-data.ts index 2c3a33ec4c..058bf236ed 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/opencode/src/cli/cmd/run/session-data.ts @@ -24,7 +24,8 @@ // `data.questions`. The footer shows whichever is first. When a reply // event arrives, the queue entry is removed and the footer falls back // to the next pending request or to the prompt view. -import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" +import type { Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" +import type { Event } from "./event" import * as Locale from "@/util/locale" import { toolView } from "./tool" import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types" diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index a94ebab522..e9e4c46e8a 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -15,7 +15,8 @@ // The tick counter prevents stale idle events from resolving the wrong turn. // We also re-check live session status before resolving an idle event so a // delayed idle from an older turn cannot complete a newer busy turn. -import type { Event, GlobalEvent, KiloClient } from "@kilocode/sdk/v2" +import type { GlobalEvent, KiloClient } from "@kilocode/sdk/v2" +import { event as normalizeEvent, type Event } from "./event" import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" import { makeRuntime } from "@/effect/run-service" import { @@ -167,12 +168,8 @@ function globalPayloadEvent(value: unknown): Event | undefined { return undefined } - const payload = value.payload - if (payload.type === "sync") { - return undefined - } - - return isEvent(payload) ? payload : undefined + const payload = normalizeEvent(value.payload) + return payload && isEvent(payload) ? payload : undefined } function isMatchingDisposeEvent(value: unknown, directory: string | undefined): boolean { diff --git a/packages/opencode/src/cli/cmd/run/subagent-data.ts b/packages/opencode/src/cli/cmd/run/subagent-data.ts index 31b8604294..7fef1d9e97 100644 --- a/packages/opencode/src/cli/cmd/run/subagent-data.ts +++ b/packages/opencode/src/cli/cmd/run/subagent-data.ts @@ -1,4 +1,5 @@ -import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" +import type { Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" +import type { Event } from "./event" import * as Locale from "@/util/locale" import { bootstrapSessionData, diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 3ef5d8018a..1fdded3676 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -227,7 +227,9 @@ export const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* return { messageCount: messages.length, - sessionCost: session.parentID ? 0 : (session.cost ?? legacyCost), + // Persisted totals may reflect step costs while parent assistant + // messages include propagated subagent cost. Keep the larger total. + sessionCost: session.parentID ? 0 : Math.max(session.cost ?? 0, legacyCost), sessionTokens, sessionTotalTokens: sessionTokens.input + diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 02e244cad0..383a11b3de 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -889,8 +889,8 @@ function App(props: { onSnapshot?: () => Promise }) { }) }) - event.on("session.deleted", (evt) => { - if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { + event.onSync("session.deleted.1", (evt) => { + if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) { route.navigate({ type: "home" }) toast.show({ variant: "info", diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index 2362cd56ca..82667dd5c1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -3,7 +3,6 @@ import { useRenderer } from "@opentui/solid" import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" import { KiloLogo } from "./kilo-logo" // kilocode_change -import * as Sound from "@tui/util/sound" import { go, logo } from "@/cli/logo" export type LogoShape = { diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts index f8f82074a1..d66a74de38 100644 --- a/packages/opencode/src/cli/cmd/tui/context/event.ts +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -1,4 +1,6 @@ -import type { Event } from "@kilocode/sdk/v2" +import type { Event, GlobalEvent } from "@kilocode/sdk/v2" + +type SyncEvent = Extract import { useProject } from "./project" import { useSDK } from "./sdk" @@ -12,10 +14,16 @@ export function useEvent() { function subscribe(handler: (event: Event, metadata: EventMetadata) => void) { return sdk.event.on("event", (event) => { - if (event.payload.type === "sync") { - return + if (event.payload.type === "sync") return + if (event.directory === "global" || event.project === project.project()) { + handler(event.payload, { workspace: event.workspace }) } + }) + } + function sync(handler: (event: SyncEvent, metadata: EventMetadata) => void) { + return sdk.event.on("event", (event) => { + if (event.payload.type !== "sync") return if (event.directory === "global" || event.project === project.project()) { handler(event.payload, { workspace: event.workspace }) } @@ -32,8 +40,20 @@ export function useEvent() { }) } + function onSync( + name: T, + handler: (event: Extract, metadata: EventMetadata) => void, + ) { + return sync((event: SyncEvent, metadata: EventMetadata) => { + if (event.name !== name) return + handler(event as Extract, metadata) + }) + } + return { subscribe, + sync, on, + onSync, } } diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index a4a51effd4..1c162fab79 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -534,8 +534,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) } - event.on("session.deleted", (evt) => { - prune(evt.properties.info.id) + event.onSync("session.deleted.1", (evt) => { + prune(evt.data.sessionID) }) if (Flag.KILO_EXPERIMENTAL_SESSION_SWITCHING) { diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index faa3e0465a..a0102e5cb2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -70,216 +70,216 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( ) } - event.subscribe((event) => { - switch (event.type) { - case "session.next.prompted": { - update(event.properties.sessionID, (draft) => { + event.sync((event) => { + switch (event.name) { + case "session.next.prompted.1": { + update(event.data.sessionID, (draft) => { draft.unshift({ id: event.id, type: "user", - text: event.properties.prompt.text, - files: event.properties.prompt.files, - agents: event.properties.prompt.agents, - time: { created: event.properties.timestamp }, + text: event.data.prompt.text, + files: event.data.prompt.files, + agents: event.data.prompt.agents, + time: { created: event.data.timestamp }, }) }) break } - case "session.next.synthetic": - update(event.properties.sessionID, (draft) => { + case "session.next.synthetic.1": + update(event.data.sessionID, (draft) => { draft.unshift({ id: event.id, type: "synthetic", - sessionID: event.properties.sessionID, - text: event.properties.text, - time: { created: event.properties.timestamp }, + sessionID: event.data.sessionID, + text: event.data.text, + time: { created: event.data.timestamp }, }) }) break - case "session.next.shell.started": - update(event.properties.sessionID, (draft) => { + case "session.next.shell.started.1": + update(event.data.sessionID, (draft) => { draft.unshift({ id: event.id, type: "shell", - callID: event.properties.callID, - command: event.properties.command, + callID: event.data.callID, + command: event.data.command, output: "", - time: { created: event.properties.timestamp }, + time: { created: event.data.timestamp }, }) }) break - case "session.next.shell.ended": - update(event.properties.sessionID, (draft) => { - const match = activeShell(draft, event.properties.callID) + case "session.next.shell.ended.1": + update(event.data.sessionID, (draft) => { + const match = activeShell(draft, event.data.callID) if (!match) return - match.output = event.properties.output - match.time.completed = event.properties.timestamp + match.output = event.data.output + match.time.completed = event.data.timestamp }) break - case "session.next.step.started": - update(event.properties.sessionID, (draft) => { + case "session.next.step.started.1": + update(event.data.sessionID, (draft) => { const currentAssistant = activeAssistant(draft) - if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp + if (currentAssistant) currentAssistant.time.completed = event.data.timestamp draft.unshift({ id: event.id, type: "assistant", - agent: event.properties.agent, - model: event.properties.model, + agent: event.data.agent, + model: event.data.model, content: [], - snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined, - time: { created: event.properties.timestamp }, + snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, + time: { created: event.data.timestamp }, }) }) break - case "session.next.step.ended": - update(event.properties.sessionID, (draft) => { + case "session.next.step.ended.1": + update(event.data.sessionID, (draft) => { const currentAssistant = activeAssistant(draft) if (!currentAssistant) return - currentAssistant.time.completed = event.properties.timestamp - currentAssistant.finish = event.properties.finish - currentAssistant.cost = event.properties.cost - currentAssistant.tokens = event.properties.tokens - if (event.properties.snapshot) - currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } + currentAssistant.time.completed = event.data.timestamp + currentAssistant.finish = event.data.finish + currentAssistant.cost = event.data.cost + currentAssistant.tokens = event.data.tokens + if (event.data.snapshot) + currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot } }) break - case "session.next.step.failed": - update(event.properties.sessionID, (draft) => { + case "session.next.step.failed.1": + update(event.data.sessionID, (draft) => { const currentAssistant = activeAssistant(draft) if (!currentAssistant) return - currentAssistant.time.completed = event.properties.timestamp + currentAssistant.time.completed = event.data.timestamp currentAssistant.finish = "error" - currentAssistant.error = event.properties.error + currentAssistant.error = event.data.error }) break - case "session.next.text.started": - update(event.properties.sessionID, (draft) => { + case "session.next.text.started.1": + update(event.data.sessionID, (draft) => { activeAssistant(draft)?.content.push({ type: "text", text: "" }) }) break - case "session.next.text.delta": - update(event.properties.sessionID, (draft) => { + case "session.next.text.delta.1": + update(event.data.sessionID, (draft) => { const match = latestText(activeAssistant(draft)) - if (match) match.text += event.properties.delta + if (match) match.text += event.data.delta }) break - case "session.next.text.ended": - update(event.properties.sessionID, (draft) => { + case "session.next.text.ended.1": + update(event.data.sessionID, (draft) => { const match = latestText(activeAssistant(draft)) - if (match) match.text = event.properties.text + if (match) match.text = event.data.text }) break - case "session.next.tool.input.started": - update(event.properties.sessionID, (draft) => { + case "session.next.tool.input.started.1": + update(event.data.sessionID, (draft) => { activeAssistant(draft)?.content.push({ type: "tool", - id: event.properties.callID, - name: event.properties.name, - time: { created: event.properties.timestamp }, + id: event.data.callID, + name: event.data.name, + time: { created: event.data.timestamp }, state: { status: "pending", input: "" }, }) }) break - case "session.next.tool.input.delta": - update(event.properties.sessionID, (draft) => { - const match = latestTool(activeAssistant(draft), event.properties.callID) - if (match?.state.status === "pending") match.state.input += event.properties.delta + case "session.next.tool.input.delta.1": + update(event.data.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.data.callID) + if (match?.state.status === "pending") match.state.input += event.data.delta }) break - case "session.next.tool.input.ended": + case "session.next.tool.input.ended.1": break - case "session.next.tool.called": - update(event.properties.sessionID, (draft) => { - const match = latestTool(activeAssistant(draft), event.properties.callID) + case "session.next.tool.called.1": + update(event.data.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.data.callID) if (!match) return - match.time.ran = event.properties.timestamp - match.provider = event.properties.provider - match.state = { status: "running", input: event.properties.input, structured: {}, content: [] } + match.time.ran = event.data.timestamp + match.provider = event.data.provider + match.state = { status: "running", input: event.data.input, structured: {}, content: [] } }) break - case "session.next.tool.progress": - update(event.properties.sessionID, (draft) => { - const match = latestTool(activeAssistant(draft), event.properties.callID) + case "session.next.tool.progress.1": + update(event.data.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.data.callID) if (match?.state.status !== "running") return - match.state.structured = event.properties.structured - match.state.content = [...event.properties.content] + match.state.structured = event.data.structured + match.state.content = [...event.data.content] }) break - case "session.next.tool.success": - update(event.properties.sessionID, (draft) => { - const match = latestTool(activeAssistant(draft), event.properties.callID) + case "session.next.tool.success.1": + update(event.data.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.data.callID) if (match?.state.status !== "running") return match.state = { status: "completed", input: match.state.input, - structured: event.properties.structured, - content: [...event.properties.content], + structured: event.data.structured, + content: [...event.data.content], } - match.provider = event.properties.provider - match.time.completed = event.properties.timestamp + match.provider = event.data.provider + match.time.completed = event.data.timestamp }) break - case "session.next.tool.failed": - update(event.properties.sessionID, (draft) => { - const match = latestTool(activeAssistant(draft), event.properties.callID) + case "session.next.tool.failed.1": + update(event.data.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.data.callID) if (match?.state.status !== "running") return match.state = { status: "error", - error: event.properties.error, + error: event.data.error, input: match.state.input, structured: match.state.structured, content: match.state.content, } - match.provider = event.properties.provider - match.time.completed = event.properties.timestamp + match.provider = event.data.provider + match.time.completed = event.data.timestamp }) break - case "session.next.reasoning.started": - update(event.properties.sessionID, (draft) => { + case "session.next.reasoning.started.1": + update(event.data.sessionID, (draft) => { activeAssistant(draft)?.content.push({ type: "reasoning", - id: event.properties.reasoningID, + id: event.data.reasoningID, text: "", }) }) break - case "session.next.reasoning.delta": - update(event.properties.sessionID, (draft) => { - const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) - if (match) match.text += event.properties.delta + case "session.next.reasoning.delta.1": + update(event.data.sessionID, (draft) => { + const match = latestReasoning(activeAssistant(draft), event.data.reasoningID) + if (match) match.text += event.data.delta }) break - case "session.next.reasoning.ended": - update(event.properties.sessionID, (draft) => { - const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) - if (match) match.text = event.properties.text + case "session.next.reasoning.ended.1": + update(event.data.sessionID, (draft) => { + const match = latestReasoning(activeAssistant(draft), event.data.reasoningID) + if (match) match.text = event.data.text }) break - case "session.next.retried": + case "session.next.retried.1": break - case "session.next.compaction.started": - update(event.properties.sessionID, (draft) => { + case "session.next.compaction.started.1": + update(event.data.sessionID, (draft) => { draft.unshift({ id: event.id, type: "compaction", - reason: event.properties.reason, + reason: event.data.reason, summary: "", - time: { created: event.properties.timestamp }, + time: { created: event.data.timestamp }, }) }) break - case "session.next.compaction.delta": - update(event.properties.sessionID, (draft) => { + case "session.next.compaction.delta.1": + update(event.data.sessionID, (draft) => { const match = activeCompaction(draft) - if (match) match.summary += event.properties.text + if (match) match.summary += event.data.text }) break - case "session.next.compaction.ended": - update(event.properties.sessionID, (draft) => { + case "session.next.compaction.ended.1": + update(event.data.sessionID, (draft) => { const match = activeCompaction(draft) if (!match) return - match.summary = event.properties.text - match.include = event.properties.include + match.summary = event.data.text + match.include = event.data.include }) break } diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index e5a255363c..5e6a6fe1ee 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -36,7 +36,6 @@ import { useToast } from "@tui/ui/toast" // kilocode_change import * as Log from "@opencode-ai/core/util/log" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" import type { IndexingStatus } from "@kilocode/kilo-indexing/status" // kilocode_change -import { KiloIndexing } from "@/kilocode/indexing" // kilocode_change import path from "path" import { useKV } from "./kv" import { aggregateFailures } from "./aggregate-failures" @@ -179,6 +178,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const fullSyncedSessions = new Set() const deleted = new Set() // kilocode_change let syncedWorkspace = project.workspace.current() + let vcsVersion = 0 // kilocode_change function sessionListQuery(): { scope?: "project"; path?: string } { if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" } @@ -345,37 +345,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("session_diff", event.properties.sessionID, event.properties.diff) break - // kilocode_change start - case "session.deleted": { - const sid = event.properties.info.id - const match = Binary.search(store.session, sid, (s) => s.id) - if (match.found) { - setStore( - "session", - produce((draft) => { - draft.splice(match.index, 1) - }), - ) - } - evict(sid) - break - } - // kilocode_change end - case "session.updated": { - const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) - if (result.found) { - setStore("session", result.index, reconcile(event.properties.info)) - break - } - setStore( - "session", - produce((draft) => { - draft.splice(result.index, 0, event.properties.info) - }), - ) - break - } - // kilocode_change start case "session.status": { setStore("session_status", event.properties.sessionID, event.properties.status) @@ -424,83 +393,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } // kilocode_change end - // kilocode_change start - case "message.updated": { - const info = strip(event.properties.info) - const messages = store.message[info.sessionID] - if (!messages) { - setStore("message", info.sessionID, [info]) - break - } - const result = Binary.search(messages, info.id, (m) => m.id) - if (result.found) { - setStore("message", info.sessionID, result.index, reconcile(info)) - break - } - setStore( - "message", - info.sessionID, - produce((draft) => { - draft.splice(result.index, 0, info) - }), - ) - const updated = store.message[info.sessionID] - if (updated.length > 100) { - const oldest = updated[0] - batch(() => { - setStore( - "message", - info.sessionID, - produce((draft) => { - draft.shift() - }), - ) - setStore( - "part", - produce((draft) => { - delete draft[oldest.id] - }), - ) - }) - } - break - } - // kilocode_change end - case "message.removed": { - const messages = store.message[event.properties.sessionID] - const result = Binary.search(messages, event.properties.messageID, (m) => m.id) - if (result.found) { - setStore( - "message", - event.properties.sessionID, - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - } - break - } - case "message.part.updated": { - const parts = store.part[event.properties.part.messageID] - if (!parts) { - setStore("part", event.properties.part.messageID, [event.properties.part]) - break - } - const result = Binary.search(parts, event.properties.part.id, (p) => p.id) - if (result.found) { - setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) - break - } - setStore( - "part", - event.properties.part.messageID, - produce((draft) => { - draft.splice(result.index, 0, event.properties.part) - }), - ) - break - } - case "message.part.delta": { const parts = store.part[event.properties.messageID] if (!parts) break @@ -519,21 +411,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } - case "message.part.removed": { - const parts = store.part[event.properties.messageID] - const result = Binary.search(parts, event.properties.partID, (p) => p.id) - if (result.found) { - setStore( - "part", - event.properties.messageID, - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - } - break - } - case "lsp.updated": { const workspace = project.workspace.current() void sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? [])) @@ -542,6 +419,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ case "vcs.branch.updated": { if (workspace === project.workspace.current()) { + vcsVersion += 1 setStore("vcs", { branch: event.properties.branch }) } break @@ -565,6 +443,140 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } }) + event.sync((event) => { + switch (event.name) { + case "session.created.1": { + const info = event.data.info + const match = Binary.search(store.session, info.id, (s) => s.id) + if (match.found) setStore("session", match.index, reconcile(info)) + if (!match.found) { + setStore( + "session", + produce((draft) => { + draft.splice(match.index, 0, info) + }), + ) + } + break + } + case "session.updated.1": { + const sid = event.data.sessionID + const match = Binary.search(store.session, sid, (s) => s.id) + if (!match.found) break + setStore( + "session", + match.index, + produce((draft) => { + Object.assign(draft, event.data.info) + }), + ) + break + } + case "session.deleted.1": { + const sid = event.data.sessionID + const match = Binary.search(store.session, sid, (s) => s.id) + if (match.found) { + setStore( + "session", + produce((draft) => { + draft.splice(match.index, 1) + }), + ) + } + evict(sid) + break + } + case "message.updated.1": { + const info = strip(event.data.info) + const messages = store.message[info.sessionID] + if (!messages) { + setStore("message", info.sessionID, [info]) + break + } + const match = Binary.search(messages, info.id, (m) => m.id) + if (match.found) { + setStore("message", info.sessionID, match.index, reconcile(info)) + break + } + setStore( + "message", + info.sessionID, + produce((draft) => { + draft.splice(match.index, 0, info) + }), + ) + const updated = store.message[info.sessionID] + if (updated.length <= 100) break + const oldest = updated[0] + batch(() => { + setStore( + "message", + info.sessionID, + produce((draft) => { + draft.shift() + }), + ) + setStore( + "part", + produce((draft) => { + delete draft[oldest.id] + }), + ) + }) + break + } + case "message.removed.1": { + const messages = store.message[event.data.sessionID] + if (!messages) break + const match = Binary.search(messages, event.data.messageID, (m) => m.id) + if (!match.found) break + setStore( + "message", + event.data.sessionID, + produce((draft) => { + draft.splice(match.index, 1) + }), + ) + break + } + case "message.part.updated.1": { + const part = event.data.part + const parts = store.part[part.messageID] + if (!parts) { + setStore("part", part.messageID, [part]) + break + } + const match = Binary.search(parts, part.id, (p) => p.id) + if (match.found) { + setStore("part", part.messageID, match.index, reconcile(part)) + break + } + setStore( + "part", + part.messageID, + produce((draft) => { + draft.splice(match.index, 0, part) + }), + ) + break + } + case "message.part.removed.1": { + const parts = store.part[event.data.messageID] + if (!parts) break + const match = Binary.search(parts, event.data.partID, (p) => p.id) + if (!match.found) break + setStore( + "part", + event.data.messageID, + produce((draft) => { + draft.splice(match.index, 1) + }), + ) + break + } + } + }) + const exit = useExit() const args = useArgs() @@ -581,6 +593,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } const projectPromise = project.sync() const sessionListPromise = projectPromise.then(() => listSessions()) + const version = vcsVersion // kilocode_change // blocking - include session.list when continuing a session const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true }) @@ -701,7 +714,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("session_status", reconcile(x.data ?? {})) }), sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), - sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), + sdk.client.vcs.get({ workspace }).then((x) => { + if (version === vcsVersion && workspace === project.workspace.current()) { + setStore("vcs", reconcile(x.data)) + } + }), project.workspace.sync(), // kilocode_change start - show config warnings as persistent toast sdk.client.config @@ -719,7 +736,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) }) .catch(() => {}), - KiloIndexing.current().then((x) => setStore("indexing", reconcile(x))), + sdk.client.indexing + .status({ workspace }) + .then((x) => setStore("indexing", reconcile(x.data ?? store.indexing))), // kilocode_change end ]).then(() => { setStore("status", "complete") diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index a14e79159c..fa643325c2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -416,8 +416,8 @@ export function Session() { }) let lastSwitch: string | undefined = undefined - event.on("message.part.updated", (evt) => { - const part = evt.properties.part + event.onSync("message.part.updated.1", (evt) => { + const part = evt.data.part if (part.type !== "tool") return if (part.sessionID !== route.sessionID) return if (part.state.status !== "completed") return diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 5a7661ca04..2ff636a166 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -229,7 +229,7 @@ export async function loadMode(dir: string, warnings?: Warning[]) { // kilocode_change start - use Effect schema (propertyOrder: original) + non-fatal handleInvalid try { result[config.name] = { - ...(ConfigParse.effectSchema(Info, config, item) as Info), + ...(ConfigParse.schema(Info, config, item) as Info), mode: "primary" as const, } } catch (err) { diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 79fa065cab..92a044813e 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -1,7 +1,7 @@ export * as ConfigCommand from "./command" import * as Log from "@opencode-ai/core/util/log" -import { Cause, Exit, Schema } from "effect" +import { Cause, Exit, Schema, SchemaIssue } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" import { Bus } from "@/bus" @@ -80,7 +80,16 @@ export async function load(dir: string, warnings?: Warning[]) { continue } // kilocode_change start - await KilocodeConfig.handleInvalid("command", item, parsed.error.issues, parsed.error, warnings) + const error = Cause.squash(parsed.cause) + const issues = Schema.isSchemaError(error) + ? SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues.map((issue) => ({ + ...issue, + message: issue.message, + path: issue.path?.map(String) ?? [], + })) + : [{ message: String(error), path: [] }] + const cause = error instanceof Error ? error : new Error(String(error)) + await KilocodeConfig.handleInvalid("command", item, issues, cause, warnings) // kilocode_change end } return result diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7c3f155269..cc3c38a99f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -526,7 +526,7 @@ export const layer = Layer.effect( const file = globalConfigFile() if (!existsSync(file)) { yield* fs - .writeWithDirs(file, JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2)) + .writeWithDirs(file, JSON.stringify({ $schema: "https://app.kilo.ai/config.json" }, null, 2)) .pipe(Effect.catch(() => Effect.void)) } } @@ -554,6 +554,7 @@ export const layer = Layer.effect( ) } + globalStamp = yield* KilocodeGlobalConfigStamp.read(fs, Global.Path.config) // kilocode_change return result }) @@ -1028,7 +1029,7 @@ export const layer = Layer.effect( worktree: ctx.worktree, config, read: readConfigFile, - parse: (input, file) => ConfigParse.schema(Info.zod, ConfigParse.jsonc(input, file), file), + parse: (input, file) => ConfigParse.schema(Info, ConfigParse.jsonc(input, file), file), patch: (input, patch) => patchJsonc(input, patch), writable, }) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config.tsx index 1416aeb2f5..5a667fe693 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config.tsx @@ -8,6 +8,9 @@ import { createContext, useContext, type ParentProps } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { TuiConfig } from "@/cli/cmd/tui/config/tui" +import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" +import { KeymapLeaderTimeoutDefault } from "@/cli/cmd/tui/config/tui-schema" +import { createBindingLookup } from "@opentui/keymap/extras" export type SetTuiConfig = (next: TuiConfig.Info) => void @@ -19,7 +22,23 @@ export namespace KiloTuiConfig { export function makeStore(initial: TuiConfig.Resolved) { const [store, setStore] = createStore(initial) const set: SetTuiConfig = (next) => { - const config = TuiConfig.resolve(next) + const keybinds = TuiKeybind.parse(next.keybinds ?? {}) + const config: TuiConfig.Resolved = { + ...next, + attention: { + enabled: next.attention?.enabled ?? false, + notifications: next.attention?.notifications ?? true, + sound: next.attention?.sound ?? true, + volume: next.attention?.volume ?? 0.4, + sound_pack: next.attention?.sound_pack ?? "opencode.default", + sounds: next.attention?.sounds ?? {}, + }, + keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }), + leader_timeout: next.leader_timeout ?? KeymapLeaderTimeoutDefault, + } if (JSON.stringify(config.keybinds.bindings) === JSON.stringify(store.keybinds.bindings)) { config.keybinds = store.keybinds } diff --git a/packages/opencode/src/kilocode/commands.ts b/packages/opencode/src/kilocode/commands.ts index 89e0cd929c..d8cefd2d55 100644 --- a/packages/opencode/src/kilocode/commands.ts +++ b/packages/opencode/src/kilocode/commands.ts @@ -8,15 +8,18 @@ import { AttachCommand } from "../cli/cmd/tui/attach" import { RunCommand } from "../cli/cmd/run" import { GenerateCommand } from "../cli/cmd/generate" import { DebugCommand } from "../cli/cmd/debug" +import { ConsoleCommand } from "../cli/cmd/account" import { ProvidersCommand } from "../cli/cmd/providers" // kilocode_change — upstream renamed auth → providers import { AgentCommand } from "../cli/cmd/agent" import { UpgradeCommand } from "../cli/cmd/upgrade" import { UninstallCommand } from "../cli/cmd/uninstall" import { ServeCommand } from "../cli/cmd/serve" +import { WebCommand } from "../cli/cmd/web" import { ModelsCommand } from "../cli/cmd/models" import { StatsCommand } from "../cli/cmd/stats" import { ExportCommand } from "../cli/cmd/export" import { ImportCommand } from "../cli/cmd/import" +import { GithubCommand } from "../cli/cmd/github" import { PrCommand } from "../cli/cmd/pr" import { SessionCommand } from "../cli/cmd/session" import { RemoteCommand } from "../cli/cmd/remote" @@ -53,17 +56,20 @@ export const commands = [ RunCommand, GenerateCommand, DebugCommand, + ConsoleCommand, ProvidersCommand, // kilocode_change — upstream renamed AuthCommand → ProvidersCommand AgentCommand, UpgradeCommand, UninstallCommand, ServeCommand, + WebCommand, ModelsCommand, RollCallCommand, ProfileCommand, StatsCommand, ExportCommand, ImportCommand, + GithubCommand, PrCommand, SessionCommand, RemoteCommand, diff --git a/packages/opencode/src/kilocode/config-validation.ts b/packages/opencode/src/kilocode/config-validation.ts index 0b7a7f2ba6..39eddb6718 100644 --- a/packages/opencode/src/kilocode/config-validation.ts +++ b/packages/opencode/src/kilocode/config-validation.ts @@ -2,9 +2,9 @@ import path from "path" import { type ParseError, parse, printParseErrorCode } from "jsonc-parser" import { Schema } from "effect" -import z from "zod" import { ConfigProtection } from "./permission/config-paths" import { ConfigMarkdown } from "@/config/markdown" +import { ConfigParse } from "@/config/parse" import { Config } from "@/config/config" import { ConfigAgent } from "@/config/agent" import { ConfigCommand } from "@/config/command" @@ -48,9 +48,16 @@ export namespace ConfigValidation { return `\n\n\nERROR: Config file at ${label(filepath)} is not valid JSON(C)\n ${detail}\n` } - const result = Config.Info.zod.safeParse(data) - if (!result.success) { - const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n") + const issues = (() => { + try { + ConfigParse.schema(Config.Info, data, filepath) + return undefined + } catch (err) { + if (err instanceof Error) return err.message + return String(err) + } + })() + if (issues) { return `\n\n\nWARNING: Configuration is invalid at ${label(filepath)}\n${issues}\n` } @@ -84,11 +91,8 @@ export namespace ConfigValidation { return `\n\n\nWARNING: Configuration is invalid at ${label(filepath)}\n${issues}\n` } } else { - const result = ConfigAgent.Info.zod.safeParse(config) - if (!result.success) { - const issues = result.error.issues - .map((i: z.core.$ZodIssue) => ` ${i.path.join(".")}: ${i.message}`) - .join("\n") + const issues = validateEffectSchema(ConfigAgent.Info, config) + if (issues) { return `\n\n\nWARNING: Configuration is invalid at ${label(filepath)}\n${issues}\n` } } diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index 6fd9f9a73e..660b29ea48 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -1,7 +1,6 @@ import path from "path" import { pathToFileURL } from "url" import { existsSync } from "fs" -import z from "zod" import { Effect, Schema } from "effect" import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { mergeDeep } from "remeda" @@ -155,8 +154,10 @@ export namespace KilocodeConfig { return undefined } - /** Format Zod issues into a human-readable string. */ - export function formatIssues(issues: z.core.$ZodIssue[]) { + type Issue = { readonly message: string; readonly path: readonly string[]; readonly [key: string]: unknown } + + /** Format schema issues into a human-readable string. */ + export function formatIssues(issues: readonly Issue[]) { return issues .map((issue) => { const loc = issue.path.map(String).join(".") @@ -170,7 +171,7 @@ export namespace KilocodeConfig { export async function handleInvalid( kind: "agent" | "command", item: string, - issues: z.core.$ZodIssue[], + issues: readonly Issue[], cause: Error, warnings?: Config.Warning[], ) { @@ -342,15 +343,21 @@ export namespace KilocodeConfig { // no global config → new user, they'll get the new bash:ask default if (existing.length === 0 && !hasLegacy) return + const configs: Array<{ file: string; data: Record }> = [] // check if any config file already has an explicit bash permission for (const file of existing) { const text = await Bun.file(file) .text() .catch(() => "") const data = parseJsonc(text) ?? {} - if (data.permission?.bash) return + configs.push({ file, data }) + if (isRecord(data.permission) && data.permission.bash) return } + // A schema-only file is generated for editor completion. It does not mean + // the user predates the bash permission default. + if (!hasLegacy && configs.every((item) => Object.keys(item.data).every((key) => key === "$schema"))) return + // also check legacy TOML config for bash permission if (hasLegacy) { const toml = await import(pathToFileURL(legacy).href, { with: { type: "toml" } }).catch(() => undefined) diff --git a/packages/opencode/src/kilocode/config/default-plugins.ts b/packages/opencode/src/kilocode/config/default-plugins.ts index f17aafcf62..6829a9efb7 100644 --- a/packages/opencode/src/kilocode/config/default-plugins.ts +++ b/packages/opencode/src/kilocode/config/default-plugins.ts @@ -1,5 +1,5 @@ import { createRequire } from "module" -import type { ConfigPlugin } from "@/config/plugin" +import { ConfigPlugin } from "@/config/plugin" import { isIndexingPlugin } from "@kilocode/kilo-indexing/detect" import { ensureAtomicChatPlugin, resolveAtomicChatPlugin } from "@/kilocode/atomic-chat-feature" import { ensureIndexingPlugin, resolveIndexingPlugin } from "@/kilocode/indexing-feature" @@ -24,7 +24,19 @@ export namespace KilocodeDefaultPlugins { cfg.plugin = plugins // Built-in indexing is not loaded through external plugins and must not wait for their setup. - cfg.plugin_origins = cfg.plugin_origins?.filter((item) => !isIndexingPlugin(item.spec)) + const origins = cfg.plugin_origins?.filter((item) => !isIndexingPlugin(item.spec)) + if (!origins) return cfg + if (opts.disabled) { + cfg.plugin_origins = origins + return cfg + } + const known = new Set(origins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))) + cfg.plugin_origins = [ + ...origins, + ...plugins + .filter((spec) => !known.has(ConfigPlugin.pluginSpecifier(spec))) + .map((spec) => ({ spec, source: "builtin", scope: "global" as const })), + ] return cfg } } diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index f6c47573fe..4751786c68 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -1,5 +1,6 @@ import path from "path" import { existsSync } from "fs" +import { Schema } from "effect" import z from "zod" import { Global } from "@opencode-ai/core/global" import { ConfigAgent } from "@/config/agent" @@ -46,9 +47,9 @@ export namespace KilocodeConfigOverlay { export const Result = z.object({ scope: Scope, - effective: Config.Info.zod, - global: Config.Info.zod, - project: Config.Info.zod, + effective: z.custom(Schema.is(Config.Info)), + global: z.custom(Schema.is(Config.Info)), + project: z.custom(Schema.is(Config.Info)), sources: z.array(KilocodeConfigSources.Source), targets: z.object({ global: z.string().optional(), @@ -176,7 +177,7 @@ export namespace KilocodeConfigOverlay { const expanded = await ConfigVariable.substitute({ text, type: "path", path: file }) const parsed = ConfigParse.jsonc(expanded, file) if (!isRecord(parsed)) return {} - return ConfigParse.effectSchema(Config.Info, parsed, file) as Config.Info + return ConfigParse.schema(Config.Info, parsed, file) as Config.Info } function field( diff --git a/packages/opencode/src/kilocode/kilo-errors.ts b/packages/opencode/src/kilocode/kilo-errors.ts index 6409f66bb7..27425c8c0e 100644 --- a/packages/opencode/src/kilocode/kilo-errors.ts +++ b/packages/opencode/src/kilocode/kilo-errors.ts @@ -1,4 +1,5 @@ import type { NamedError } from "@opencode-ai/core/util/error" +import { isRecord } from "@/util/record" export const KILO_ERROR_CODES = { PAID_MODEL_AUTH_REQUIRED: "PAID_MODEL_AUTH_REQUIRED", @@ -69,7 +70,7 @@ export function showKiloErrorToast( */ export function parseKiloErrorCode(error: ReturnType): KiloErrorCode | undefined { if (error.name !== "APIError") return undefined - const responseBody = error.data?.responseBody + const responseBody = isRecord(error.data) ? error.data.responseBody : undefined if (typeof responseBody !== "string") return undefined try { const body = JSON.parse(responseBody) diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index fc5f7aa3b1..73098ccd37 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -17,7 +17,7 @@ import { MessageV2 } from "@/session/message-v2" import { SessionStatus } from "@/session/status" import { Todo } from "@/session/todo" import { makeRuntime } from "@/effect/run-service" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Log from "@opencode-ai/core/util/log" import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" import { lazy } from "@/util/lazy" @@ -178,7 +178,12 @@ export namespace PlanFollowup { const ModelState = z .object({ - model: z.record(z.string(), z.object({ providerID: ProviderID.zod, modelID: ModelID.zod })).optional(), + model: z + .record( + z.string(), + z.object({ providerID: z.custom(Schema.is(ProviderID)), modelID: z.custom(Schema.is(ModelID)) }), + ) + .optional(), variant: z.record(z.string(), z.string().optional()).optional(), }) .passthrough() diff --git a/packages/opencode/src/kilocode/session/cost-propagation.ts b/packages/opencode/src/kilocode/session/cost-propagation.ts index 2eb3daef7f..631d665de3 100644 --- a/packages/opencode/src/kilocode/session/cost-propagation.ts +++ b/packages/opencode/src/kilocode/session/cost-propagation.ts @@ -58,7 +58,7 @@ export namespace KiloCostPropagation { Effect.promise(() => acquire(`${sid}:${mid}`)), () => Effect.gen(function* () { - const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: sid, messageID: mid })) + const parent = yield* MessageV2.get({ sessionID: sid, messageID: mid }) if (parent.info.role !== "assistant") return parent.info.cost += amount yield* sessions.updateMessage(parent.info) diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index f63db2f71b..3db9fd383e 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -270,16 +270,24 @@ export namespace KiloSession { // These helpers catch that specific error and log a warning instead. // --------------------------------------------------------------------------- - export function runSyncSafe(run: () => void, context: { type: string; id: string; sessionID: string }): void { - try { - run() - } catch (e: any) { - if (e?.code === "SQLITE_CONSTRAINT_FOREIGNKEY") { - log.warn(`skipping ${context.type} for deleted session`, { id: context.id, sessionID: context.sessionID }) - return - } - throw e - } + export function runSyncSafe( + run: Effect.Effect, + context: { type: string; id: string; sessionID: string }, + ) { + return run.pipe( + Effect.catchCause((cause) => { + const err = Cause.squash(cause) + if (typeof err === "object" && err !== null && "code" in err && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") { + return Effect.sync(() => + log.warn(`skipping ${context.type} for deleted session`, { + id: context.id, + sessionID: context.sessionID, + }), + ) + } + return Effect.failCause(cause) + }), + ) } // --------------------------------------------------------------------------- @@ -289,7 +297,7 @@ export namespace KiloSession { /** Schema for project summary returned by listGlobal. */ export const ProjectInfo = z .object({ - id: ProjectID.zod, + id: z.custom(Schema.is(ProjectID)), name: z.string().optional(), worktree: z.string(), }) diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index c7fae25e18..5f2be34b31 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -1,5 +1,5 @@ // kilocode_change - new file -import { Effect } from "effect" +import { Effect, Schema } from "effect" import path from "path" import { Permission } from "@/permission" import { Flag } from "@opencode-ai/core/flag/flag" @@ -17,7 +17,12 @@ const log = Log.create({ service: "kilocode-task-model" }) // RATIONALE: Mirror narrow state slice Task tool consumes and ignore unrelated TUI fields. const ModelState = z .object({ - model: z.record(z.string(), z.object({ providerID: ProviderID.zod, modelID: ModelID.zod })).optional(), + model: z + .record( + z.string(), + z.object({ providerID: z.custom(Schema.is(ProviderID)), modelID: z.custom(Schema.is(ModelID)) }), + ) + .optional(), variant: z.record(z.string(), z.string().optional()).optional(), }) .passthrough() @@ -130,7 +135,7 @@ export namespace KiloTask { if (!choice) continue if (choice.direct) return { model: choice.model, variant: choice.variant } const full = yield* input.provider.getModel(choice.model.providerID, choice.model.modelID).pipe( - Effect.catchDefect((err) => + Effect.catchTag("ProviderModelNotFoundError", (err) => Effect.sync(() => { log.debug("skipping unavailable task subagent model", { providerID: choice.model.providerID, diff --git a/packages/opencode/src/kilocode/tui/config.ts b/packages/opencode/src/kilocode/tui/config.ts index d0988f12f6..a5d8beeeec 100644 --- a/packages/opencode/src/kilocode/tui/config.ts +++ b/packages/opencode/src/kilocode/tui/config.ts @@ -1,6 +1,6 @@ import path from "path" import z from "zod" -import { Effect, Layer } from "effect" +import { Effect, Layer, Schema } from "effect" import { applyEdits, modify } from "jsonc-parser" import { mergeDeep } from "remeda" import { Global } from "@opencode-ai/core/global" @@ -8,6 +8,7 @@ import { ConfigParse } from "@/config/parse" import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd" import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiInfo } from "@/cli/cmd/tui/config/tui-schema" +import { KilocodeKeybinds } from "./keybinds" import { Filesystem } from "@/util/filesystem" import { isRecord } from "@/util/record" import { GlobalBus } from "@/bus/global" @@ -18,7 +19,7 @@ export namespace KilocodeTuiConfig { export type Scope = z.infer export const Patch = TuiInfo - export type Patch = z.output + export type Patch = Schema.Schema.Type export type Editable = Omit & { keybinds?: Record } const files = ["tui.jsonc", "tui.json"] as const @@ -108,20 +109,20 @@ export namespace KilocodeTuiConfig { } function merge(base: Patch, patch: Patch): Patch { - return writable(mergeDeep(base, patch)) + return writable(mergeDeep(base, patch), false) } - function writable(config: TuiConfig.Info): Editable { + function writable(config: Patch | TuiConfig.Info, defaults = true): Editable { const result = { ...config } as Record delete result.plugin_origins - const keybinds = Object.fromEntries( - Object.entries(config.keybinds ?? {}).flatMap(([key, value]) => { - if (typeof value === "string") return [[key, value]] - if (value === false) return [[key, "none"]] - return [] - }), - ) - if (config.keybinds) result.keybinds = keybinds + const keybinds: Record = defaults + ? Object.fromEntries(KilocodeKeybinds.list().map((item) => [item.id, item.default])) + : {} + for (const [key, value] of Object.entries(config.keybinds ?? {})) { + if (typeof value === "string") keybinds[key] = value + if (value === false) keybinds[key] = "none" + } + if (defaults || config.keybinds) result.keybinds = keybinds else delete result.keybinds for (const key of Object.keys(result)) { diff --git a/packages/opencode/src/kilocode/tui/keybinds.ts b/packages/opencode/src/kilocode/tui/keybinds.ts index 00acdca61b..ec1f335ae8 100644 --- a/packages/opencode/src/kilocode/tui/keybinds.ts +++ b/packages/opencode/src/kilocode/tui/keybinds.ts @@ -60,12 +60,12 @@ export namespace KilocodeKeybinds { } export function list(): Info[] { - return Object.entries(TuiKeybind.Keybinds.shape).map(([id, schema]) => ({ + return Object.entries(TuiKeybind.Definitions).map(([id, definition]) => ({ id, label: label(id), group: group(id), - default: fallback(id, schema.parse(undefined)), - description: TuiKeybind.Descriptions[id as keyof typeof TuiKeybind.Descriptions] ?? label(id), + default: fallback(id, definition.default), + description: definition.description, })) } } diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index bd212e76d4..165bb4477b 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -265,7 +265,7 @@ export const layer = Layer.effect( if (s.broken.has(root + server.id)) continue // kilocode_change start - use lightweight tsgo-based client when persistent LSP is not enabled - if (server.id === "typescript" && !Flag.KILO_EXPERIMENTAL_LSP_TOOL) { + if (server.id === "typescript" && !flags.experimentalLspTool) { const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) if (existing) { result.push(existing) diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index 750acb91b4..6714805789 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -4,7 +4,7 @@ import { Context, Duration, Effect, Layer, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Config } from "../config/config" import { Auth } from "../auth" -import type { Provider } from "./models" +import type { Provider } from "@opencode-ai/core/models" import * as Log from "@opencode-ai/core/util/log" type Models = Provider["models"] diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts new file mode 100644 index 0000000000..b5c0bb1ebf --- /dev/null +++ b/packages/opencode/src/provider/models.ts @@ -0,0 +1,110 @@ +// kilocode_change - adapt Kilo model assembly to the upstream core models service +import { Config } from "@/config/config" +import { Auth } from "@/auth" +import { ModelCache } from "./model-cache" +import * as Core from "@opencode-ai/core/models" +import { Context, Effect, Layer } from "effect" +import { AI_SDK_PROVIDERS, KILO_OPENROUTER_BASE, PROMPTS } from "@kilocode/kilo-gateway" + +export const Model = Core.Model +export type Model = Core.Model +export const Provider = Core.Provider +export type Provider = Core.Provider +export const CatalogModelStatus = Core.CatalogModelStatus +export type CatalogModelStatus = Core.CatalogModelStatus + +export interface Interface extends Core.Interface {} + +export class Service extends Context.Service()("@opencode/ModelsDev") {} + +function baseURL(url: string | undefined, org: string | undefined) { + if (!url) return + const base = url.replace(/\/+$/, "") + if (org) { + if (base.includes("/api/organizations/")) return base + if (base.endsWith("/api")) return `${base}/organizations/${org}` + return `${base}/api/organizations/${org}` + } + if (base.includes("/openrouter")) return base + if (base.endsWith("/api")) return `${base}/openrouter` + return `${base}/api/openrouter` +} + +export const layer: Layer.Layer< + Service, + never, + Core.Service | Config.Service | Auth.Service | ModelCache.Service +> = Layer.effect( + Service, + Effect.gen(function* () { + const core = yield* Core.Service + const config = yield* Config.Service + const auth = yield* Auth.Service + const cache = yield* ModelCache.Service + + const get = Effect.fn("ModelsDev.get")(function* () { + const providers = { ...(yield* core.get()) } + delete providers.kilo + + const cfg = yield* config.get() + const disabled = new Set(cfg.disabled_providers ?? []) + const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : undefined + const allowed = (!enabled || enabled.has("kilo")) && !disabled.has("kilo") + const apt = cfg.provider?.apertis?.options + const aptURL = apt?.baseURL ?? "https://api.apertis.ai/v1" + const aptOpts = apt?.baseURL ? { baseURL: apt.baseURL } : {} + + const addApertis = Effect.fnUntraced(function* () { + if (providers.apertis) return + const models = yield* cache.fetch("apertis", aptOpts).pipe(Effect.catch(() => Effect.succeed({}))) + providers.apertis = { + id: "apertis", + name: "Apertis", + env: ["APERTIS_API_KEY"], + api: aptURL, + npm: "@ai-sdk/openai-compatible", + models, + } + if (Object.keys(models).length === 0) yield* cache.refresh("apertis", aptOpts).pipe(Effect.ignore, Effect.forkDetach) + }) + + if (!allowed) { + yield* addApertis() + return providers + } + + const opts = cfg.provider?.kilo?.options + const info = yield* auth.get("kilo").pipe(Effect.catch(() => Effect.succeed(undefined))) + const org = opts?.kilocodeOrganizationId ?? (info?.type === "oauth" ? info.accountId : undefined) + const url = baseURL(opts?.baseURL, org) + const fetch = { + ...(url ? { baseURL: url } : {}), + ...(org ? { kilocodeOrganizationId: org } : {}), + } + const models = yield* cache.fetch("kilo", fetch).pipe(Effect.catch(() => Effect.succeed({}))) + providers.kilo = { + id: "kilo", + name: "Kilo Gateway", + env: ["KILO_API_KEY"], + api: KILO_OPENROUTER_BASE.endsWith("/") ? KILO_OPENROUTER_BASE : `${KILO_OPENROUTER_BASE}/`, + npm: "@kilocode/kilo-gateway", + models, + } + if (Object.keys(models).length === 0) yield* cache.refresh("kilo", fetch).pipe(Effect.ignore, Effect.forkDetach) + yield* addApertis() + return providers + }) + + return Service.of({ get, refresh: core.refresh }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Core.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(ModelCache.defaultLayer), +) + +export { AI_SDK_PROVIDERS, PROMPTS } +export * as ModelsDev from "./models" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e5b9c46bb4..7e373a4b0c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -8,7 +8,7 @@ import { Npm } from "@opencode-ai/core/npm" import { Hash } from "@opencode-ai/core/util/hash" import { Plugin } from "../plugin" import { type LanguageModelV3 } from "@ai-sdk/provider" -import * as ModelsDev from "@opencode-ai/core/models" +import * as ModelsDev from "./models" // kilocode_change - assemble dynamic Kilo models around upstream core catalog import { Auth } from "../auth" import { Env } from "../env" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -1024,7 +1024,7 @@ export interface Interface { providerID: ProviderID, query: string[], ) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined> - readonly getSmallModel: (providerID: ProviderID) => Effect.Effect + readonly getSmallModel: (providerID: ProviderID) => Effect.Effect readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }> } diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 61d99fa1fa..e84c74733f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -284,7 +284,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", error instanceof Image.SizeError ) return Effect.fail(new HttpApiError.BadRequest({})) - return Effect.failCause(cause) + return Effect.die(error) }), // kilocode_change end ) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index d39adc72de..82ad7aaa81 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -723,7 +723,7 @@ export const layer: Layer.Layer< return Service.of({ isOverflow, prune, - process: processCompaction, + process: (input) => processCompaction(input).pipe(Effect.orDie), create, }) }), diff --git a/packages/opencode/src/session/network.ts b/packages/opencode/src/session/network.ts index f9a8416a62..cae83ecd3b 100644 --- a/packages/opencode/src/session/network.ts +++ b/packages/opencode/src/session/network.ts @@ -306,7 +306,7 @@ export namespace SessionNetwork { export const restore = fn( z.object({ - requestID: QuestionID.zod, + requestID: z.custom((value) => typeof value === "string" && value.startsWith("que")), }), async (input) => { const s = await state() @@ -330,7 +330,7 @@ export namespace SessionNetwork { export const reply = fn( z.object({ - requestID: QuestionID.zod, + requestID: z.custom((value) => typeof value === "string" && value.startsWith("que")), }), async (input) => { const s = await state() @@ -379,7 +379,7 @@ export namespace SessionNetwork { export const reject = fn( z.object({ - requestID: QuestionID.zod, + requestID: z.custom((value) => typeof value === "string" && value.startsWith("que")), }), async (input) => { const s = await state() diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 36bdab73a9..c9ed95863b 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -22,7 +22,6 @@ import { Question } from "@/question" import { KiloSessionProcessor, type ReviewTelemetry } from "@/kilocode/session/processor" import { KiloSessionOverflow } from "@/kilocode/session/overflow" import { Suggestion } from "@/kilocode/suggestion" -import { NotFoundError } from "@/storage/storage" // kilocode_change end import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" @@ -196,14 +195,10 @@ export const layer: Layer.Layer< // kilocode_change start - tolerate deleted sessions during subagent cost reconciliation (#6321) const reconcile = Effect.fn("SessionProcessor.reconcileCost")(function* () { - const fresh = yield* Effect.sync(() => { - try { - return MessageV2.get({ sessionID: ctx.assistantMessage.sessionID, messageID: ctx.assistantMessage.id }) - } catch (err) { - if (NotFoundError.isInstance(err)) return - throw err - } - }) + const fresh = yield* MessageV2.get({ + sessionID: ctx.assistantMessage.sessionID, + messageID: ctx.assistantMessage.id, + }).pipe(Effect.catchTag("NotFoundError", () => Effect.void)) if (fresh?.info.role !== "assistant") return if (fresh.info.cost <= ctx.assistantMessage.cost) return ctx.assistantMessage.cost = fresh.info.cost diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0915b3bca3..67e6abc7f0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -12,6 +12,7 @@ import { Question } from "@/question" // kilocode_change import { zod } from "@opencode-ai/core/effect-zod" // kilocode_change import { withStatics } from "@opencode-ai/core/schema" // kilocode_change import { SessionID, MessageID, PartID } from "./schema" +import type { NotFoundError } from "@/storage/storage" import { MessageV2 } from "./message-v2" import * as Log from "@opencode-ai/core/util/log" import { SessionRevert } from "./revert" @@ -232,7 +233,7 @@ export const layer = Layer.effect( cancel: (sessionID: SessionID) => cancel(sessionID), resolvePromptParts: (template: string) => resolvePromptParts(template), prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)), - loop: (input: LoopInput) => loop(input), + loop: (input: LoopInput) => loop(input).pipe(Effect.orDie), } satisfies TaskPromptOps }) @@ -1741,11 +1742,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the return yield* KiloSessionPromptQueue.enqueue( input.sessionID, message.info.id, - bridge.run(loop({ sessionID: input.sessionID, snapshotInitialization: input.snapshotInitialization })), // kilocode_change + bridge.run( + loop({ sessionID: input.sessionID, snapshotInitialization: input.snapshotInitialization }).pipe(Effect.orDie), + ), // kilocode_change bridge.run(lastAssistant(input.sessionID)), ) // kilocode_change end }, + Effect.catchTag("NotFoundError", Effect.die), ) const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) { @@ -1765,7 +1769,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the const closeReasons = new Map() // kilocode_change start - retain request-scoped snapshot initialization policy - const runLoop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.run")(function* ( + const runLoop: (input: LoopInput) => Effect.Effect = Effect.fn( + "SessionPrompt.run", + )(function* ( input: LoopInput, ) { const sessionID = input.sessionID @@ -2152,7 +2158,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the return yield* lastAssistant(sessionID) }) - const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")(function* ( + const loop: (input: LoopInput) => Effect.Effect = Effect.fn( + "SessionPrompt.loop", + )(function* ( input: LoopInput, ) { // kilocode_change start @@ -2160,7 +2168,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* KiloSessionPrompt.recoverProviderFinishError({ sessionID: input.sessionID, status, sessions }) yield* bus.publish(KiloSession.Event.TurnOpen, { sessionID: input.sessionID }) return yield* Effect.onExit( - state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input)), // kilocode_change + state.ensureRunning( + input.sessionID, + lastAssistant(input.sessionID).pipe(Effect.orDie), + runLoop(input).pipe(Effect.orDie), + ), // kilocode_change Effect.fnUntraced(function* (exit) { yield* bus.publish(KiloSession.Event.TurnClose, { sessionID: input.sessionID, @@ -2179,7 +2191,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the "SessionPrompt.shell", )(function* (input: ShellInput) { const ready = yield* Latch.make() - return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input, ready), ready) + return yield* state.startShell( + input.sessionID, + lastAssistant(input.sessionID).pipe(Effect.orDie), + shellImpl(input, ready), + ready, + ) }) const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { @@ -2304,7 +2321,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return Service.of({ cancel, prompt, - loop, + loop: (input) => loop(input).pipe(Effect.orDie), shell, command, resolvePromptParts, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 2c94a82618..fa775e075f 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -661,11 +661,9 @@ export const layer: Layer.Layer< const updateMessage = (msg: T): Effect.Effect => Effect.gen(function* () { // kilocode_change start - ignore FK errors when session was deleted while processor was still running - yield* Effect.sync(() => - KiloSession.runSyncSafe( - () => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }), - { type: "message update", id: msg.id, sessionID: msg.sessionID }, - ), + yield* KiloSession.runSyncSafe( + sync.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }), + { type: "message update", id: msg.id, sessionID: msg.sessionID }, ) // kilocode_change end return msg @@ -674,16 +672,13 @@ export const layer: Layer.Layer< const updatePart = (part: T): Effect.Effect => Effect.gen(function* () { // kilocode_change start - ignore FK errors when session was deleted while processor was still running - yield* Effect.sync(() => - KiloSession.runSyncSafe( - () => - SyncEvent.run(MessageV2.Event.PartUpdated, { - sessionID: part.sessionID, - part: structuredClone(part), - time: Date.now(), - }), - { type: "part update", id: part.id, sessionID: part.sessionID }, - ), + yield* KiloSession.runSyncSafe( + sync.run(MessageV2.Event.PartUpdated, { + sessionID: part.sessionID, + part: structuredClone(part), + time: Date.now(), + }), + { type: "part update", id: part.id, sessionID: part.sessionID }, ) // kilocode_change end return part diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index aeaf42dcee..32ae0483ed 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -145,7 +145,6 @@ export const ApplyPatchTool = Tool.define( ) newContent = fileUpdate.content bom = fileUpdate.bom - encoding = fileUpdate.encoding // kilocode_change } catch (error) { return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`)) } diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index fc97848baa..8c8fc21467 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -112,13 +112,24 @@ export const ReadTool = Tool.define( const lines = Effect.fn("ReadTool.lines")((filepath: string, opts: { limit: number; offset: number }) => // kilocode_change - extracted formats still need their native readers; ordinary text stays on AppFileSystem - Effect.tryPromise(() => Extract.open(filepath)).pipe( + Effect.tryPromise({ + try: () => Extract.open(filepath), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( Effect.flatMap((extracted) => extracted - ? Effect.tryPromise(() => collect(extracted, opts)) + ? Effect.tryPromise({ + try: () => collect(extracted, opts), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }) : fs.readFile(filepath).pipe( Effect.map((bytes) => Encoding.decode(Buffer.from(bytes), Encoding.detect(Buffer.from(bytes)))), - Effect.flatMap((text) => Effect.tryPromise(() => collect(Readable.from([text]), opts))), + Effect.flatMap((text) => + Effect.tryPromise({ + try: () => collect(Readable.from([text]), opts), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }), + ), ), ), ), @@ -198,7 +209,7 @@ export const ReadTool = Tool.define( Effect.catch(() => Effect.succeed(new Uint8Array())), ) if (isBinaryFile(child, sample)) return - const file = yield* Effect.promise(() => lines(child, { limit: DEFAULT_READ_LIMIT, offset: 1 })).pipe( + const file = yield* lines(child, { limit: DEFAULT_READ_LIMIT, offset: 1 }).pipe( Effect.catch(() => Effect.void), ) if (!file) return diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 6800e5d128..aa074dea26 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -393,8 +393,12 @@ export const TaskTool = Tool.define( Effect.ensuring( Effect.gen(function* () { ctx.abort.removeEventListener("abort", onAbort) - const costAfter = yield* KiloCostPropagation.childCost(sessions, nextSession.id) - yield* KiloCostPropagation.propagate(sessions, ctx.sessionID, ctx.messageID, costAfter - costBefore) + const costAfter = yield* KiloCostPropagation.childCost(sessions, nextSession.id).pipe( + Effect.catchTag("NotFoundError", () => Effect.succeed(costBefore)), + ) + yield* KiloCostPropagation.propagate(sessions, ctx.sessionID, ctx.messageID, costAfter - costBefore).pipe( + Effect.catchTag("NotFoundError", () => Effect.void), + ) }), ), ), diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 5e9e742cfb..cb9e3950b4 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test" import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { - Event, - EventMessagePartUpdated, + GlobalEvent, + SyncEventMessagePartUpdated, ToolStateCompleted, ToolStatePending, ToolStateRunning, @@ -30,10 +30,7 @@ type SessionUpdateParams = Parameters[0] type RequestPermissionParams = Parameters[0] type RequestPermissionResult = Awaited> -type GlobalEventEnvelope = { - directory?: string - payload?: Event -} +type GlobalEventEnvelope = Partial type EventController = { push: (event: GlobalEventEnvelope) => void @@ -86,10 +83,13 @@ function toolEvent( input: opts.input, raw: opts.raw, } - const payload: EventMessagePartUpdated = { + const payload: SyncEventMessagePartUpdated = { id: `evt_${opts.callID}`, - type: "message.part.updated", - properties: { + type: "sync", + name: "message.part.updated.1", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: sessionId, time: Date.now(), part: { @@ -126,10 +126,13 @@ function completedToolEvent( time: { start: Date.now() - 1, end: Date.now() }, ...(opts.attachments && { attachments: opts.attachments }), } - const payload: EventMessagePartUpdated = { + const payload: SyncEventMessagePartUpdated = { id: `evt_${opts.callID}`, - type: "message.part.updated", - properties: { + type: "sync", + name: "message.part.updated.1", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: sessionId, time: Date.now(), part: { @@ -382,8 +385,12 @@ describe("acp.agent event subscription", () => { controller.push({ directory: cwd, payload: { - type: "message.part.updated", - properties: { + type: "sync", + name: "message.part.updated.1", + id: "evt_part_1", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: sessionId, time: Date.now(), part: { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index e0defc1386..8253471efb 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -51,7 +51,7 @@ it.instance("returns default native agents when no config", () => Effect.gen(function* () { const agents = yield* load((svc) => svc.list()) const names = agents.map((a) => a.name) - expect(names).toContain("build") + expect(names).toContain("code") expect(names).toContain("plan") expect(names).toContain("general") expect(names).toContain("explore") @@ -69,7 +69,7 @@ it.instance("build agent has correct default properties", () => expect(build?.mode).toBe("primary") expect(build?.native).toBe(true) expect(evalPerm(build, "edit")).toBe("allow") - expect(evalPerm(build, "bash")).toBe("allow") + expect(evalPerm(build, "bash")).toBe("ask") expect(evalPerm(build, "repo_clone")).toBe("deny") expect(evalPerm(build, "repo_overview")).toBe("deny") }), @@ -128,16 +128,16 @@ scout.instance("scout agent allows repo cloning and repo cache reads", () => ) scout.instance( - "reference config does not create subagents", + "reference config creates scout-backed subagents", () => Effect.gen(function* () { const agents = yield* load((svc) => svc.list()) const names = agents.map((agent) => agent.name) expect(names).toContain("scout") - expect(names).not.toContain("effect") - expect(names).not.toContain("effectFull") - expect(names).not.toContain("localdocs") - expect(names).not.toContain("localdocsFull") + expect(names).toContain("effect") + expect(names).toContain("effectFull") + expect(names).toContain("localdocs") + expect(names).toContain("localdocsFull") }), { config: { @@ -623,17 +623,17 @@ description: Permission skill. { git: true }, ) -it.instance("defaultAgent returns build when no default_agent config", () => +it.instance("defaultAgent returns code when no default_agent config", () => Effect.gen(function* () { const agent = yield* load((svc) => svc.defaultAgent()) - expect(agent).toBe("build") + expect(agent).toBe("code") }), ) -it.instance("defaultInfo returns resolved build agent when no default_agent config", () => +it.instance("defaultInfo returns resolved code agent when no default_agent config", () => Effect.gen(function* () { const agent = yield* load((svc) => svc.defaultInfo()) - expect(agent.name).toBe("build") + expect(agent.name).toBe("code") expect(agent.mode).toBe("primary") }), ) @@ -724,8 +724,11 @@ it.instance( { config: { agent: { - build: { disable: true }, + code: { disable: true }, plan: { disable: true }, + debug: { disable: true }, + orchestrator: { disable: true }, + ask: { disable: true }, }, }, }, diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index c437281cc6..ef173d073b 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -24,6 +24,7 @@ const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "age const provider = ProviderTest.fake() const configLayer = Config.layer.pipe( + Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(AuthTest.empty), @@ -32,19 +33,18 @@ const configLayer = Config.layer.pipe( ) const pluginLayer = Plugin.layer.pipe( Layer.provide(Bus.layer), - Layer.provide(configLayer), Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ) +const dependencies = Layer.mergeAll(configLayer, pluginLayer).pipe(Layer.provideMerge(configLayer)) const agentLayer = Agent.layer.pipe( - Layer.provide(configLayer), Layer.provide(AuthTest.empty), Layer.provide(SkillTest.empty), Layer.provide(provider.layer), - Layer.provide(pluginLayer), Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ) +const layer = Layer.mergeAll(agentLayer, dependencies).pipe(Layer.provideMerge(dependencies)) -const it = testEffect(Layer.mergeAll(agentLayer, pluginLayer)) +const it = testEffect(layer) it.instance( "plugin-registered agents appear in Agent.list", diff --git a/packages/opencode/test/cli/cmd/tui/app-exit.test.ts b/packages/opencode/test/cli/cmd/tui/app-exit.test.ts index d1adf4fb8e..bd23ee682c 100644 --- a/packages/opencode/test/cli/cmd/tui/app-exit.test.ts +++ b/packages/opencode/test/cli/cmd/tui/app-exit.test.ts @@ -1,6 +1,7 @@ // kilocode_change - new file import { describe, expect, test } from "bun:test" -import { TuiConfig } from "../../../../src/cli/cmd/tui/config/tui" +import { createBindingLookup } from "@opentui/keymap/extras" +import { TuiKeybind } from "../../../../src/cli/cmd/tui/config/keybind" import * as AppExit from "../../../../src/kilocode/tui/app-exit" const prompt = (focused: boolean, input: string): AppExit.Prompt => ({ @@ -10,7 +11,10 @@ const prompt = (focused: boolean, input: string): AppExit.Prompt => ({ describe("app_exit", () => { test("blocks exit when the command matcher is disabled", () => { - const bindings = TuiConfig.resolve({}).keybinds.gather("app_exit", ["app.exit"]) + const bindings = createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse({})), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }).gather("app_exit", ["app.exit"]) expect(bindings.length).toBeGreaterThan(0) expect(AppExit.enabled(false)).toBe(false) diff --git a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx index defed7d89e..f152b649c7 100644 --- a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx @@ -65,8 +65,6 @@ export function createFetch(override?: FetchHandler) { switch (url.pathname) { case "/agent": case "/command": - case "/experimental/workspace": - case "/experimental/workspace/status": case "/formatter": case "/lsp": case "/network": // kilocode_change @@ -89,6 +87,15 @@ export function createFetch(override?: FetchHandler) { return json({ id: "proj_test" }) case "/provider": return json({ all: [], default: {}, connected: [] }) + case "/experimental/workspace": + return json([ + { id: "ws_a", type: "local", branch: "a", name: "a", directory: "/tmp/a", projectID: "proj_test" }, + { id: "ws_b", type: "local", branch: "b", name: "b", directory: "/tmp/b", projectID: "proj_test" }, + ]) + case "/experimental/workspace/status": + return json([]) + case "/indexing/status": // kilocode_change + return json({ state: "Disabled", message: "Indexing disabled.", processedFiles: 0, totalFiles: 0, percent: 0 }) case "/session": return json([]) case "/vcs": diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index b29ca2b3ba..20d63c64ba 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -73,7 +73,7 @@ describe("cli.error", () => { const expected = [ "Model not found: anthropic/claude-sonet-4", "Did you mean: claude-sonnet-4", - "Try: `opencode models` to list available models", + "Try: `kilo models` to list available models", "Or check your config (opencode.json) provider/model names", ].join("\n") diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index 0a551beb4f..1934261374 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -3,9 +3,9 @@ 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" -type EventStream = Awaited>["stream"] +type SdkEvent = GlobalEvent["payload"] +type EventStream = AsyncGenerator type GlobalEventStream = Awaited>["stream"] -type SdkEvent = EventStream extends AsyncGenerator ? T : never type SessionMessage = NonNullable>["data"]>[number] type SessionChild = NonNullable>["data"]>[number] type SessionToolPart = Extract @@ -67,19 +67,22 @@ function idle(sessionID = "session-1") { } satisfies SdkEvent } -function assistant(id: string) { +function assistant(id: string, sessionID = "session-1"): SdkEvent { return { id: `evt-${id}`, - type: "message.updated", - properties: { - sessionID: "session-1", + type: "sync", + name: "message.updated.1", + seq: 1, + aggregateID: "sessionID", + data: { + sessionID, info: assistantMessage({ - sessionID: "session-1", + sessionID, id, parts: [], }).info, }, - } satisfies SdkEvent + } } function feed() { @@ -269,8 +272,11 @@ function textPart(id: string, messageID: string, text: string, sessionID = "sess function textUpdated(part: TextPart): SdkEvent { return { id: `evt-${part.id}-updated`, - type: "message.part.updated", - properties: { + type: "sync", + name: "message.part.updated.1", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: part.sessionID, part, time: 1, @@ -281,8 +287,11 @@ function textUpdated(part: TextPart): SdkEvent { function toolUpdated(part: SessionToolPart): SdkEvent { return { id: `evt-${part.id}-updated`, - type: "message.part.updated", - properties: { + type: "sync", + name: "message.part.updated.1", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: part.sessionID, part, time: 1, @@ -363,7 +372,6 @@ function sdk( input: { stream?: EventStream globalStream?: GlobalEventStream - subscribe?: KiloClient["event"]["subscribe"] globalEvent?: KiloClient["global"]["event"] promptAsync?: KiloClient["session"]["promptAsync"] status?: KiloClient["session"]["status"] @@ -375,7 +383,6 @@ function sdk( ) { const client = new KiloClient() - const subscribe: KiloClient["event"]["subscribe"] = input.subscribe ?? (() => sse(input.stream ?? emptyStream())) const globalEvent: KiloClient["global"]["event"] = input.globalEvent ?? (() => globalSse(input.globalStream ?? wrapGlobalStream(input.stream ?? emptyStream()))) const promptAsync: KiloClient["session"]["promptAsync"] = input.promptAsync ?? (() => ok(undefined)) @@ -385,7 +392,6 @@ function sdk( const permissions: KiloClient["permission"]["list"] = input.permissions ?? (() => ok([])) const questions: KiloClient["question"]["list"] = input.questions ?? (() => ok([])) - spyOn(client.event, "subscribe").mockImplementation(subscribe) spyOn(client.global, "event").mockImplementation(globalEvent) spyOn(client.session, "promptAsync").mockImplementation(promptAsync) spyOn(client.session, "status").mockImplementation(status) @@ -757,18 +763,7 @@ describe("run stream transport", () => { transport.selectSubagent("child-1") global.push( - globalEvent({ - id: "evt-child-message", - type: "message.updated", - properties: { - sessionID: "child-1", - info: assistantMessage({ - sessionID: "child-1", - id: "msg-child-1", - parts: [], - }).info, - }, - }), + globalEvent(assistant("msg-child-1", "child-1")), ) global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "hello", "child-1")))) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f0f834d282..746423f7bf 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -159,8 +159,8 @@ test("creates global jsonc config with schema when no global configs exist", asy }, }) - const content = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc")) - expect(content).toContain('"$schema": "https://opencode.ai/config.json"') + const content = await Filesystem.readText(path.join(tmp.path, "kilo.jsonc")) + expect(content).toContain('"$schema": "https://app.kilo.ai/config.json"') } finally { ;(Global.Path as { config: string }).config = prev await clear(true) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 571b9a6f3e..4a7cda2f81 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "url" import { Effect, Layer } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" import { Config } from "@/config/config" import { ConfigPlugin } from "@/config/plugin" import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd" @@ -29,9 +30,18 @@ const cleanState = Effect.gen(function* () { const withCleanState = (self: Effect.Effect) => Effect.acquireUseRelease( - cleanState, + Effect.gen(function* () { + const disabled = Flag.KILO_DISABLE_DEFAULT_PLUGINS + Flag.KILO_DISABLE_DEFAULT_PLUGINS = true + yield* cleanState + return disabled + }), () => self, - () => cleanState, + (disabled) => + Effect.gen(function* () { + Flag.KILO_DISABLE_DEFAULT_PLUGINS = disabled + yield* cleanState + }), ) const withEnv = (name: string, value: string | undefined, self: Effect.Effect) => diff --git a/packages/opencode/test/kilocode/bash-permission-metadata.test.ts b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts index c5190d0ef7..dea0138049 100644 --- a/packages/opencode/test/kilocode/bash-permission-metadata.test.ts +++ b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts @@ -13,6 +13,7 @@ import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Plugin } from "../../src/plugin" import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" const runtime = ManagedRuntime.make( Layer.mergeAll( @@ -22,6 +23,7 @@ const runtime = ManagedRuntime.make( Truncate.defaultLayer, Agent.defaultLayer, Config.defaultLayer, + RuntimeFlags.layer(), ), ) diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/context/tui-config.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/context/tui-config.test.ts index 9b0bae42c0..a2e7f96ae3 100644 --- a/packages/opencode/test/kilocode/cli/cmd/tui/context/tui-config.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/tui/context/tui-config.test.ts @@ -5,6 +5,8 @@ */ import { describe, expect, test } from "bun:test" import { createEffect, createRoot } from "solid-js" +import { createBindingLookup } from "@opentui/keymap/extras" +import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { KiloTuiConfig } from "@/kilocode/cli/cmd/tui/context/tui-config" @@ -12,9 +14,29 @@ function cfg(input: Partial): TuiConfig.Info { return input as TuiConfig.Info } +function resolve(input: TuiConfig.Info): TuiConfig.Resolved { + const keybinds = TuiKeybind.parse(input.keybinds ?? {}) + return { + ...input, + attention: { + enabled: input.attention?.enabled ?? false, + notifications: input.attention?.notifications ?? true, + sound: input.attention?.sound ?? true, + volume: input.attention?.volume ?? 0.4, + sound_pack: input.attention?.sound_pack ?? "opencode.default", + sounds: input.attention?.sounds ?? {}, + }, + keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }), + leader_timeout: input.leader_timeout ?? 1_000, + } +} + describe("KiloTuiConfig.makeStore", () => { test("reactive reads update when set() reconciles a new config", () => { - const store = KiloTuiConfig.makeStore(TuiConfig.resolve(cfg({ keybinds: { app_exit: "ctrl+c" }, theme: "kilo" }))) + const store = KiloTuiConfig.makeStore(resolve(cfg({ keybinds: { app_exit: "ctrl+c" }, theme: "kilo" }))) const exits: Array = [] const themes: Array = [] @@ -44,7 +66,7 @@ describe("KiloTuiConfig.makeStore", () => { }) test("set() does not re-notify a tracked read when its value is unchanged", () => { - const store = KiloTuiConfig.makeStore(TuiConfig.resolve(cfg({ keybinds: { app_exit: "ctrl+c" }, theme: "kilo" }))) + const store = KiloTuiConfig.makeStore(resolve(cfg({ keybinds: { app_exit: "ctrl+c" }, theme: "kilo" }))) const exits: Array = [] let dispose!: () => void diff --git a/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts b/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts index 36df07576c..dc8e46ee86 100644 --- a/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts +++ b/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts @@ -5,6 +5,7 @@ import * as Stream from "effect/Stream" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Image } from "../../src/image/image" import { KiloCompactionPayloadRecovery } from "../../src/kilocode/session/compaction-payload-recovery" import { KiloSessionCompaction } from "../../src/kilocode/session/compaction" @@ -195,6 +196,7 @@ function runtime(layer: Layer.Layer, config = Config.defaultLayer) Layer.provide(status), Layer.provide(bus), Layer.provide(config), + Layer.provide(RuntimeFlags.layer()), Layer.provide(SyncEvent.defaultLayer), ), ) diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index bcf3651f6a..55c2039a6b 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -1,6 +1,6 @@ // kilocode_change - new file import { afterEach, describe, expect, test } from "bun:test" -import { Effect, Layer, Option } from "effect" +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" @@ -54,6 +54,17 @@ async function writeConfig(dir: string, config: object, name = "kilo.json") { await Filesystem.write(path.join(dir, name), JSON.stringify(config)) } +function decode(input: unknown): Config.Info { + const config = Schema.decodeUnknownSync(Config.Info)(input) + return { + ...config, + skills: config.skills && { + paths: config.skills.paths && [...config.skills.paths], + urls: config.skills.urls && [...config.skills.urls], + }, + } +} + const cfg: Partial = { plugin: ["@kilocode/kilo-indexing"], indexing: { @@ -186,7 +197,7 @@ describe("kilocode indexing config", () => { }) test("accepts delete sentinels for indexing model overrides", () => { - const patch = Config.Info.zod.parse({ indexing: { model: null, dimension: null } }) + const patch = decode({ indexing: { model: null, dimension: null } }) const merged = KilocodeConfig.mergeConfig( { indexing: { @@ -207,7 +218,7 @@ describe("kilocode indexing config", () => { describe("agent config", () => { test("accepts delete sentinels for agent model and variant overrides", () => { - const patch = Config.Info.zod.parse({ agent: { explore: { model: null, variant: null } } }) + const patch = decode({ agent: { explore: { model: null, variant: null } } }) const merged = KilocodeConfig.mergeConfig( { agent: { @@ -226,7 +237,7 @@ describe("agent config", () => { }) test("removes an agent variant override without removing its model", () => { - const patch = Config.Info.zod.parse({ agent: { explore: { variant: null } } }) + const patch = decode({ agent: { explore: { variant: null } } }) const merged = KilocodeConfig.mergeConfig( { agent: { @@ -267,7 +278,7 @@ describe("agent config", () => { "}", ].join("\n"), ) - const patch = Config.Info.zod.parse({ agent: { explore: { model: null, variant: null } } }) + const patch = decode({ agent: { explore: { model: null, variant: null } } }) await saveGlobal(patch) diff --git a/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts b/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts index 7975c9cc84..7bfdf95a23 100644 --- a/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts +++ b/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect, Layer, Option } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import path from "path" +import { Flag } from "@opencode-ai/core/flag/flag" import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" import { Account } from "../../../src/account/account" import { Auth } from "../../../src/auth" @@ -44,12 +45,8 @@ const layer = Config.layer.pipe( ) const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) -const clear = () => - Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer))) - describe("kilocode default indexing plugin", () => { afterEach(async () => { - await clear() await disposeAllInstances() }) @@ -76,32 +73,31 @@ describe("kilocode default indexing plugin", () => { }) test("does not hard-enable indexing plugin when default plugins are disabled", async () => { - const prev = process.env["KILO_DISABLE_DEFAULT_PLUGINS"] - process.env["KILO_DISABLE_DEFAULT_PLUGINS"] = "true" + const original = Flag.KILO_DISABLE_DEFAULT_PLUGINS + Flag.KILO_DISABLE_DEFAULT_PLUGINS = true try { await using tmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - plugin: ["global-plugin-1"], - }), - ) - }, - }) + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://app.kilo.ai/config.json", + plugin: ["global-plugin-1"], + }), + ) + }, + }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { const config = await load() expect(hasIndexingPlugin(config.plugin ?? [])).toBe(false) }, }) } finally { - if (prev === undefined) delete process.env["KILO_DISABLE_DEFAULT_PLUGINS"] - else process.env["KILO_DISABLE_DEFAULT_PLUGINS"] = prev + Flag.KILO_DISABLE_DEFAULT_PLUGINS = original } }) }) diff --git a/packages/opencode/test/kilocode/config/opentelemetry-default.test.ts b/packages/opencode/test/kilocode/config/opentelemetry-default.test.ts index e3711c417d..53ab3b1147 100644 --- a/packages/opencode/test/kilocode/config/opentelemetry-default.test.ts +++ b/packages/opencode/test/kilocode/config/opentelemetry-default.test.ts @@ -1,30 +1,31 @@ // kilocode_change - new file import { describe, expect, test } from "bun:test" import { Config } from "../../../src/config/config" +import { Schema } from "effect" describe("Config.Info experimental.openTelemetry default", () => { test("defaults to true when experimental is set without openTelemetry", () => { - const parsed = Config.Info.zod.parse({ experimental: {} }) + const parsed = Schema.decodeUnknownSync(Config.Info)({ experimental: {} }) expect(parsed.experimental?.openTelemetry).toBe(true) }) test("defaults to true when openTelemetry is explicitly undefined", () => { - const parsed = Config.Info.zod.parse({ experimental: { openTelemetry: undefined } }) + const parsed = Schema.decodeUnknownSync(Config.Info)({ experimental: { openTelemetry: undefined } }) expect(parsed.experimental?.openTelemetry).toBe(true) }) test("respects explicit false", () => { - const parsed = Config.Info.zod.parse({ experimental: { openTelemetry: false } }) + const parsed = Schema.decodeUnknownSync(Config.Info)({ experimental: { openTelemetry: false } }) expect(parsed.experimental?.openTelemetry).toBe(false) }) test("respects explicit true", () => { - const parsed = Config.Info.zod.parse({ experimental: { openTelemetry: true } }) + const parsed = Schema.decodeUnknownSync(Config.Info)({ experimental: { openTelemetry: true } }) expect(parsed.experimental?.openTelemetry).toBe(true) }) test("experimental stays undefined when not set at all", () => { - const parsed = Config.Info.zod.parse({}) + const parsed = Schema.decodeUnknownSync(Config.Info)({}) expect(parsed.experimental).toBeUndefined() }) }) diff --git a/packages/opencode/test/kilocode/config/speech-to-text-config.test.ts b/packages/opencode/test/kilocode/config/speech-to-text-config.test.ts index c4cf66fac8..32b46a4b1f 100644 --- a/packages/opencode/test/kilocode/config/speech-to-text-config.test.ts +++ b/packages/opencode/test/kilocode/config/speech-to-text-config.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from "bun:test" import { Config } from "../../../src/config/config" +import { Schema } from "effect" describe("Config.Info experimental speech-to-text model", () => { test("parses the selected speech-to-text model", () => { - const parsed = Config.Info.zod.parse({ + const parsed = Schema.decodeUnknownSync(Config.Info)({ experimental: { speech_to_text_model: "openai/gpt-4o-mini-transcribe", }, @@ -13,7 +14,7 @@ describe("Config.Info experimental speech-to-text model", () => { }) test("keeps existing experimental defaults", () => { - const parsed = Config.Info.zod.parse({ experimental: { speech_to_text_model: "google/chirp-3" } }) + const parsed = Schema.decodeUnknownSync(Config.Info)({ experimental: { speech_to_text_model: "google/chirp-3" } }) expect(parsed.experimental?.openTelemetry).toBe(true) }) }) diff --git a/packages/opencode/test/kilocode/cost-propagation.test.ts b/packages/opencode/test/kilocode/cost-propagation.test.ts index 4f2d85dd5a..ad7d0c8782 100644 --- a/packages/opencode/test/kilocode/cost-propagation.test.ts +++ b/packages/opencode/test/kilocode/cost-propagation.test.ts @@ -69,7 +69,7 @@ describe("KiloCostPropagation.propagate", () => { deltas.map((d) => KiloCostPropagation.propagate(sessions, chat.id, assistant.id, d)), { concurrency: "unbounded" }, ) - const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + const parent = yield* MessageV2.get({ sessionID: chat.id, messageID: assistant.id }) expect(parent.info.role).toBe("assistant") if (parent.info.role !== "assistant") return const total = deltas.reduce((a, b) => a + b, 0) @@ -85,7 +85,7 @@ describe("KiloCostPropagation.propagate", () => { const { chat, assistant } = yield* seed() yield* KiloCostPropagation.propagate(sessions, chat.id, assistant.id, 0) yield* KiloCostPropagation.propagate(sessions, chat.id, assistant.id, -1.5) - const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + const parent = yield* MessageV2.get({ sessionID: chat.id, messageID: assistant.id }) if (parent.info.role !== "assistant") return expect(parent.info.cost).toBe(0) }), diff --git a/packages/opencode/test/kilocode/custom-provider-delete.test.ts b/packages/opencode/test/kilocode/custom-provider-delete.test.ts index b721485b65..ae371891d5 100644 --- a/packages/opencode/test/kilocode/custom-provider-delete.test.ts +++ b/packages/opencode/test/kilocode/custom-provider-delete.test.ts @@ -10,11 +10,12 @@ import { describe, expect, it } from "bun:test" import * as Config from "../../src/config/config" +import { Schema } from "effect" import { KilocodeConfig } from "../../src/kilocode/config/config" describe("Config.Info — null sentinels for custom provider deletes", () => { it("accepts a null model value inside a provider", () => { - const parsed = Config.Info.zod.safeParse({ + const parsed = Schema.decodeUnknownResult(Config.Info)({ provider: { myprovider: { name: "My Provider", @@ -24,20 +25,20 @@ describe("Config.Info — null sentinels for custom provider deletes", () => { }, }, }) - expect(parsed.success).toBe(true) + expect(parsed._tag).toBe("Success") }) it("accepts a null provider value", () => { - const parsed = Config.Info.zod.safeParse({ + const parsed = Schema.decodeUnknownResult(Config.Info)({ provider: { myprovider: null, }, }) - expect(parsed.success).toBe(true) + expect(parsed._tag).toBe("Success") }) it("accepts a null variant value inside a model", () => { - const parsed = Config.Info.zod.safeParse({ + const parsed = Schema.decodeUnknownResult(Config.Info)({ provider: { myprovider: { name: "My Provider", @@ -51,7 +52,7 @@ describe("Config.Info — null sentinels for custom provider deletes", () => { }, }, }) - expect(parsed.success).toBe(true) + expect(parsed._tag).toBe("Success") }) }) diff --git a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts index b14ba50678..a53dbd1431 100644 --- a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts +++ b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts @@ -3,15 +3,17 @@ import { expect } from "bun:test" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { ModelsDev } from "../../src/provider/models" +import * as CoreModels from "@opencode-ai/core/models" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { kiloCustomLoaders } from "../../src/kilocode/provider/provider" import { Auth } from "../../src/auth" import { ModelCache } from "../../src/provider/model-cache" -import { ModelsDev } from "../../src/provider/models" import { Provider } from "../../src/provider/provider" import { TestConfig } from "../fixture/config" import { testEffect } from "../lib/effect" +import { provideInstance } from "../fixture/fixture" const input = { id: "kilo", @@ -96,7 +98,15 @@ function layer() { Layer.provide(auth), Layer.provide(models), ) + const core = Layer.succeed( + CoreModels.Service, + CoreModels.Service.of({ + get: () => Effect.succeed(seed), + refresh: () => Effect.void, + }), + ) return Layer.fresh(ModelsDev.layer).pipe( + Layer.provide(core), Layer.provide(FetchHttpClient.layer), Layer.provide(files), Layer.provide(cfg), @@ -109,7 +119,10 @@ const it = testEffect(Layer.empty) it.live("assembles paid Kilo models without auth", () => Effect.gen(function* () { - const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe(Effect.provide(layer())) + const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe( + Effect.provide(layer()), + provideInstance(process.cwd()), + ) const kilo = Provider.fromModelsDevProvider(providers.kilo) expect(kilo.models["paid-model"]).toMatchObject({ @@ -122,7 +135,10 @@ it.live("assembles paid Kilo models without auth", () => it.live("marks zero-cost Kilo models as free when the catalog omits isFree", () => Effect.gen(function* () { - const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe(Effect.provide(layer())) + const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe( + Effect.provide(layer()), + provideInstance(process.cwd()), + ) const kilo = Provider.fromModelsDevProvider(providers.kilo) expect(kilo.models["free-model"].isFree).toBe(true) diff --git a/packages/opencode/test/kilocode/local-model.test.ts b/packages/opencode/test/kilocode/local-model.test.ts index c15c90feb4..95bca4d0ca 100644 --- a/packages/opencode/test/kilocode/local-model.test.ts +++ b/packages/opencode/test/kilocode/local-model.test.ts @@ -93,6 +93,7 @@ mock.module("@tui/context/sync", () => ({ provider_default: { anthropic: "claude-sonnet" }, agent: mockAgents, config: mockConfig, + session: [], mcp: {}, }, }), @@ -127,6 +128,9 @@ mock.module("@tui/context/sdk", () => ({ connect: async () => {}, }, }, + event: { + on: () => () => {}, + }, }), })) diff --git a/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts b/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts index caad026aa1..6baee85611 100644 --- a/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts +++ b/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts @@ -10,6 +10,7 @@ import { TsCheck } from "../../src/kilocode/ts-check" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance, type InstanceContext } from "../../src/project/instance" import { disposeAllInstances } from "../fixture/fixture" +import type { RuntimeFlags } from "../../src/effect/runtime-flags" afterEach(async () => { await disposeAllInstances() @@ -17,6 +18,7 @@ afterEach(async () => { // Typescript.spawn doesn't use ctx, so a cast-through is fine for these tests. const fakeCtx = {} as InstanceContext +const fakeFlags = {} as RuntimeFlags.Info describe("typescript lightweight mode", () => { describe("spawn gate", () => { @@ -24,7 +26,7 @@ describe("typescript lightweight mode", () => { const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL Flag.KILO_EXPERIMENTAL_LSP_TOOL = false try { - const result = await LSPServer.Typescript.spawn("/tmp/any", fakeCtx) + const result = await LSPServer.Typescript.spawn("/tmp/any", fakeCtx, fakeFlags) expect(result).toBeUndefined() } finally { Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved @@ -37,7 +39,7 @@ describe("typescript lightweight mode", () => { const spy = spyOn(TsCheck, "native_tsgo").mockResolvedValue(undefined) try { - const result = await LSPServer.Typescript.spawn("/tmp/any", fakeCtx) + const result = await LSPServer.Typescript.spawn("/tmp/any", fakeCtx, fakeFlags) expect(spy).toHaveBeenCalled() expect(result).toBeUndefined() // undefined because mock returns no binary } finally { @@ -83,7 +85,7 @@ describe("typescript lightweight mode", () => { test("lsp/lsp.ts uses TsClient for lightweight diagnostics", async () => { const src = await Bun.file(path.resolve(import.meta.dir, "../../src/lsp/lsp.ts")).text() expect(src).toContain("TsClient.create") - expect(src).toContain("KILO_EXPERIMENTAL_LSP_TOOL") + expect(src).toContain("flags.experimentalLspTool") }) }) }) diff --git a/packages/opencode/test/kilocode/patch.test.ts b/packages/opencode/test/kilocode/patch.test.ts index 86ef219546..c44ce53a14 100644 --- a/packages/opencode/test/kilocode/patch.test.ts +++ b/packages/opencode/test/kilocode/patch.test.ts @@ -3,15 +3,55 @@ // legacy single-byte, CJK). // - Plain UTF-8 files do not gain a spurious BOM. // - Moved files keep the original encoding at the new path. -// These round-trip through Patch.applyPatch directly so we exercise the -// encoding + BOM integration in patch/index.ts without the tool stack. +// These round-trip through the apply_patch tool so the Kilo encoding layer is +// exercised with the upstream patch parser. import { afterEach, beforeEach, describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { tmpdir } from "os" import iconv from "iconv-lite" -import { Patch } from "../../src/patch" +import { Effect, Layer } from "effect" +import { Agent } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +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" + +const layer = Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + Bus.layer, + Format.defaultLayer, + LSP.defaultLayer, + Truncate.defaultLayer, +) + +const apply = (dir: string, patchText: string) => + Effect.runPromise( + Effect.gen(function* () { + const info = yield* ApplyPatchTool + const tool = yield* Tool.init(info) + yield* tool.execute( + { patchText }, + { + sessionID: SessionID.make("ses_patch"), + messageID: MessageID.make("msg_patch"), + callID: "call_patch", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + }).pipe(provideInstance(dir), Effect.scoped, Effect.provide(layer)), + ) const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]) const UTF16_LE_BOM = Buffer.from([0xff, 0xfe]) @@ -39,7 +79,7 @@ describe("Patch encoding preservation", () => { +line 2 updated *** End Patch` - await Patch.applyPatch(patch) + await apply(dir, patch) const bytes = await fs.readFile(file) expect(bytes.subarray(0, 3).equals(UTF8_BOM)).toBe(true) @@ -58,7 +98,7 @@ describe("Patch encoding preservation", () => { +line 2 updated *** End Patch` - await Patch.applyPatch(patch) + await apply(dir, patch) const bytes = await fs.readFile(file) expect(bytes[0]).not.toBe(0xef) @@ -77,7 +117,7 @@ describe("Patch encoding preservation", () => { +line 2 updated *** End Patch` - await Patch.applyPatch(patch) + await apply(dir, patch) const bytes = await fs.readFile(file) expect(bytes.subarray(0, 2).equals(UTF16_LE_BOM)).toBe(true) @@ -96,7 +136,7 @@ describe("Patch encoding preservation", () => { +águila *** End Patch` - await Patch.applyPatch(patch) + await apply(dir, patch) const bytes = await fs.readFile(file) expect(iconv.decode(bytes, "iso-8859-1")).toBe("café\náguila\n") @@ -122,7 +162,7 @@ describe("Patch encoding preservation", () => { line3 *** End Patch` - await Patch.applyPatch(patch) + await apply(dir, patch) const bytes = await fs.readFile(file) expect(iconv.decode(bytes, "Shift_JIS")).toBe("line1\nさようなら、世界!\nline3\n") @@ -143,7 +183,7 @@ describe("Patch encoding preservation", () => { +updated *** End Patch` - await Patch.applyPatch(patch) + await apply(dir, patch) const moved = await fs.readFile(to) expect(moved.subarray(0, 3).equals(UTF8_BOM)).toBe(true) @@ -163,10 +203,10 @@ describe("Patch encoding preservation", () => { +hello world *** End Patch` - await Patch.applyPatch(patch) + await apply(dir, patch) const bytes = await fs.readFile(file) expect(bytes[0]).not.toBe(0xef) - expect(bytes.toString("utf-8")).toBe("hello world") + expect(bytes.toString("utf-8")).toBe("hello world\n") }) }) diff --git a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts index 3b4c4e6e9a..f1af05cb64 100644 --- a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts +++ b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts @@ -7,6 +7,7 @@ import { Global } from "@opencode-ai/core/global" import { Agent } from "../../../src/agent/agent" 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 { WithInstance } from "../../../src/project/with-instance" @@ -24,6 +25,7 @@ const runtime = ManagedRuntime.make( CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Config.defaultLayer, + RuntimeFlags.layer(), Plugin.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, diff --git a/packages/opencode/test/kilocode/provider-list-failed-state.test.ts b/packages/opencode/test/kilocode/provider-list-failed-state.test.ts index 26b37353d9..9c9acc9611 100644 --- a/packages/opencode/test/kilocode/provider-list-failed-state.test.ts +++ b/packages/opencode/test/kilocode/provider-list-failed-state.test.ts @@ -7,18 +7,18 @@ import { beforeEach, expect } from "bun:test" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" +import type { ModelsDev } from "@opencode-ai/core/models" import * as Log from "@opencode-ai/core/util/log" Log.init({ print: false }) import { Auth } from "../../src/auth" import { ModelCache } from "../../src/provider/model-cache" -import type { Provider } from "../../src/provider/models" import { TestConfig } from "../fixture/config" import { testEffect } from "../lib/effect" type Failure = { kind: "unauthorized" | "network" | "schema" | "http"; status?: number } -type Result = { models: Provider["models"]; error?: Failure } +type Result = { models: ModelsDev.Provider["models"]; error?: Failure } let result: Result = { models: {} } let error: Error | undefined diff --git a/packages/opencode/test/kilocode/server/httpapi-kilo-edit.test.ts b/packages/opencode/test/kilocode/server/httpapi-kilo-edit.test.ts index 2223330b29..0b63dc0513 100644 --- a/packages/opencode/test/kilocode/server/httpapi-kilo-edit.test.ts +++ b/packages/opencode/test/kilocode/server/httpapi-kilo-edit.test.ts @@ -4,7 +4,7 @@ import { HttpRouter } from "effect/unstable/http" import { HEADER_FEATURE, HEADER_ORGANIZATIONID } from "@kilocode/kilo-gateway" import * as Log from "@opencode-ai/core/util/log" import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway" -import { ExperimentalHttpApiServer } from "../../../src/server/routes/instance/httpapi/server" +import * as HttpApiServer from "../../../src/server/routes/instance/httpapi/server" import { resetDatabase } from "../../fixture/db" import { disposeAllInstances, tmpdir } from "../../fixture/fixture" @@ -30,7 +30,7 @@ const edit = { function app() { const handler = HttpRouter.toWebHandler( - ExperimentalHttpApiServer.routes.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({})))), + HttpApiServer.routes.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({})))), { disableLogger: true }, ).handler @@ -38,7 +38,7 @@ function app() { request(input: string | URL | Request, init?: RequestInit) { return handler( input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), - ExperimentalHttpApiServer.context, + HttpApiServer.context, ) }, } diff --git a/packages/opencode/test/kilocode/session-compaction-cap.test.ts b/packages/opencode/test/kilocode/session-compaction-cap.test.ts index abe58dbcef..f38dd97e39 100644 --- a/packages/opencode/test/kilocode/session-compaction-cap.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-cap.test.ts @@ -8,9 +8,11 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { Agent as AgentSvc } from "../../src/agent/agent" +import { BackgroundJob } from "../../src/background/job" import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { Env } from "../../src/env" import { Ripgrep } from "../../src/file/ripgrep" @@ -128,6 +130,7 @@ const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLaye function makeHttp() { const deps = Layer.mergeAll( Session.defaultLayer, + BackgroundJob.defaultLayer, Snapshot.defaultLayer, LLM.defaultLayer, Env.defaultLayer, @@ -136,6 +139,7 @@ function makeHttp() { Permission.defaultLayer, plugin, Config.defaultLayer, + RuntimeFlags.layer(), ProviderSvc.defaultLayer, lsp, mcp, diff --git a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts index bf73d70827..8c5ecf1205 100644 --- a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts @@ -4,6 +4,7 @@ import * as Stream from "effect/Stream" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Image } from "../../src/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" @@ -219,11 +220,12 @@ function runtime(layer: Layer.Layer, context = 7_000) { Layer.provide(Agent.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.layer()), Layer.provide(status), Layer.provide(bus), Layer.provide( Layer.mock(Config.Service)({ - get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }), + get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }), }), ), ), @@ -284,10 +286,11 @@ function fakeRuntime() { Layer.provide(Agent.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.layer()), Layer.provide(bus), Layer.provide( Layer.mock(Config.Service)({ - get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }), + get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }), }), ), ), @@ -314,11 +317,12 @@ function liveRuntime(layer: Layer.Layer, context = 10_000) { Layer.provide(Agent.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.layer()), Layer.provide(status), Layer.provide(bus), Layer.provide( Layer.mock(Config.Service)({ - get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }), + get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }), }), ), ), diff --git a/packages/opencode/test/kilocode/session-overflow.test.ts b/packages/opencode/test/kilocode/session-overflow.test.ts index 201ab85454..e5655a85c1 100644 --- a/packages/opencode/test/kilocode/session-overflow.test.ts +++ b/packages/opencode/test/kilocode/session-overflow.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Schema } from "effect" import type { ModelMessage } from "ai" import { Config } from "@/config/config" import type { Provider } from "@/provider/provider" @@ -7,8 +8,15 @@ import { KiloSessionOverflow } from "@/kilocode/session/overflow" import type { MessageV2 } from "@/session/message-v2" import { isOverflow, usable } from "@/session/overflow" -function cfg(compaction?: Config.Info["compaction"]) { - return Config.Info.zod.parse({ compaction }) +function cfg(compaction?: Config.Info["compaction"]): Config.Info { + const config = Schema.decodeUnknownSync(Config.Info)({ compaction }) + return { + ...config, + skills: config.skills && { + paths: config.skills.paths && [...config.skills.paths], + urls: config.skills.urls && [...config.skills.urls], + }, + } } function model(opts: { context: number; output: number; input?: number }): Provider.Model { diff --git a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts index 75949c0c38..b318976bca 100644 --- a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts +++ b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts @@ -6,6 +6,7 @@ import path from "path" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Image } from "../../src/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" @@ -102,6 +103,7 @@ const deps = Layer.mergeAll( Permission.defaultLayer, Plugin.defaultLayer, Config.defaultLayer, + RuntimeFlags.layer(), SessionSummary.defaultLayer, Image.defaultLayer, SyncEvent.defaultLayer, diff --git a/packages/opencode/test/kilocode/session-processor-network-offline.test.ts b/packages/opencode/test/kilocode/session-processor-network-offline.test.ts index 2dd18ff5dc..abdb5fc55d 100644 --- a/packages/opencode/test/kilocode/session-processor-network-offline.test.ts +++ b/packages/opencode/test/kilocode/session-processor-network-offline.test.ts @@ -6,6 +6,7 @@ import path from "path" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Image } from "../../src/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" @@ -101,6 +102,7 @@ const deps = Layer.mergeAll( Permission.defaultLayer, Plugin.defaultLayer, Config.defaultLayer, + RuntimeFlags.layer(), SessionSummary.defaultLayer, Image.defaultLayer, SyncEvent.defaultLayer, diff --git a/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts b/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts index 91944a86bc..827e9c0cf3 100644 --- a/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts +++ b/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts @@ -13,6 +13,7 @@ import path from "path" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Image } from "../../src/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" @@ -114,6 +115,7 @@ const deps = Layer.mergeAll( Permission.defaultLayer, Plugin.defaultLayer, Config.defaultLayer, + RuntimeFlags.layer(), SessionSummary.defaultLayer, Image.defaultLayer, SyncEvent.defaultLayer, diff --git a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts index ffb4e394fa..22c1cae000 100644 --- a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts @@ -7,9 +7,11 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { Agent as AgentSvc } from "../../src/agent/agent" +import { BackgroundJob } from "../../src/background/job" import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { Env } from "../../src/env" import { Ripgrep } from "../../src/file/ripgrep" @@ -121,6 +123,7 @@ const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLaye function makeHttp() { const deps = Layer.mergeAll( Session.defaultLayer, + BackgroundJob.defaultLayer, Snapshot.defaultLayer, LLM.defaultLayer, Env.defaultLayer, @@ -129,10 +132,12 @@ function makeHttp() { Permission.defaultLayer, plugin, Config.defaultLayer, + RuntimeFlags.layer(), ProviderSvc.defaultLayer, lsp, mcp, AppFileSystem.defaultLayer, + Reference.defaultLayer, SyncEvent.defaultLayer, status, ).pipe(Layer.provideMerge(infra)) @@ -173,7 +178,9 @@ function makeHttp() { Layer.provide(SystemPrompt.defaultLayer), Layer.provideMerge(deps), ), - ).pipe(Layer.provide(summary)) + ).pipe( + Layer.provide(Layer.mergeAll(summary, deps, Config.defaultLayer, RuntimeFlags.layer(), BackgroundJob.defaultLayer)), + ) } const it = testEffect(makeHttp()) diff --git a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts index cac14071d4..168977c9ac 100644 --- a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts @@ -7,9 +7,11 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import * as Log from "@opencode-ai/core/util/log" import { Agent as AgentSvc } from "../../src/agent/agent" +import { BackgroundJob } from "../../src/background/job" import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Env } from "../../src/env" import { Format } from "../../src/format" import { Git } from "../../src/git" @@ -115,6 +117,7 @@ const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLaye function makeHttp() { const deps = Layer.mergeAll( Session.defaultLayer, + BackgroundJob.defaultLayer, Snapshot.defaultLayer, LLM.defaultLayer, Env.defaultLayer, @@ -123,10 +126,12 @@ function makeHttp() { Permission.defaultLayer, Plugin.defaultLayer, Config.defaultLayer, + RuntimeFlags.layer(), ProviderSvc.defaultLayer, lsp, mcp, AppFileSystem.defaultLayer, + Reference.defaultLayer, SyncEvent.defaultLayer, status, ).pipe(Layer.provideMerge(infra)) @@ -162,11 +167,14 @@ function makeHttp() { Layer.provideMerge(proc), Layer.provideMerge(registry), Layer.provideMerge(trunc), + Layer.provideMerge(question), Layer.provide(Instruction.defaultLayer), Layer.provide(SystemPrompt.defaultLayer), Layer.provideMerge(deps), ), - ).pipe(Layer.provide(summary)) + ).pipe( + Layer.provide(Layer.mergeAll(summary, deps, Config.defaultLayer, RuntimeFlags.layer(), BackgroundJob.defaultLayer)), + ) } const it = testEffect(makeHttp()) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 76de375fdb..4e6c57ebaf 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -609,7 +609,7 @@ describe("session prompt queue", () => { const result = await Effect.runPromise( KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_probe"), + MessageID.make("msg_probe"), Effect.succeed("work executed"), Effect.succeed("cancelled returned"), ), diff --git a/packages/opencode/test/kilocode/session-share.test.ts b/packages/opencode/test/kilocode/session-share.test.ts index 280c0147e6..4f83bee74d 100644 --- a/packages/opencode/test/kilocode/session-share.test.ts +++ b/packages/opencode/test/kilocode/session-share.test.ts @@ -3,13 +3,16 @@ import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Auth } from "../../src/auth" import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Session } from "../../src/session/session" import { SessionShare } from "../../src/share/session" import { Storage } from "../../src/storage/storage" import { SyncEvent } from "../../src/sync" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Auth.defaultLayer, Storage.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + Layer.mergeAll(Auth.defaultLayer, Storage.defaultLayer, CrossSpawnSpawner.defaultLayer, RuntimeFlags.layer()), +) const layer = SessionShare.layer.pipe( Layer.provideMerge(Session.defaultLayer), diff --git a/packages/opencode/test/kilocode/session/session.test.ts b/packages/opencode/test/kilocode/session/session.test.ts index b6ced5c244..323b7091b9 100644 --- a/packages/opencode/test/kilocode/session/session.test.ts +++ b/packages/opencode/test/kilocode/session/session.test.ts @@ -5,6 +5,8 @@ import { Bus } from "../../../src/bus" import * as Log from "@opencode-ai/core/util/log" import { WithInstance } from "../../../src/project/with-instance" import { AppRuntime } from "../../../src/effect/app-runtime" +import { RuntimeFlags } from "../../../src/effect/runtime-flags" +import { Effect } from "effect" import { tmpdir } from "../../fixture/fixture" import type { SessionID } from "../../../src/session/schema" @@ -31,12 +33,15 @@ describe("session.created event", () => { let eventReceived = false let receivedInfo: SessionNs.Info | undefined + const title = `created-event-${Date.now()}` const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => { + const info = event.properties.info as SessionNs.Info + if (info.title !== title) return eventReceived = true - receivedInfo = event.properties.info as SessionNs.Info + receivedInfo = info }) - const info = await create({}) + const info = await create({ title }) await new Promise((resolve) => setTimeout(resolve, 100)) unsub() @@ -54,31 +59,43 @@ describe("session.created event", () => { }) test("session.created event should be emitted before session.updated", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const events: string[] = [] + const previous = process.env.KILO_EXPERIMENTAL_WORKSPACES + delete process.env.KILO_EXPERIMENTAL_WORKSPACES + try { + await WithInstance.provide({ + directory: projectRoot, + fn: async () => { + const flags = AppRuntime.runSync(Effect.service(RuntimeFlags.Service)) + const enabled = flags.experimentalWorkspaces + Object.assign(flags, { experimentalWorkspaces: false }) + const events: string[] = [] + const title = `event-order-${Date.now()}` - const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => { - events.push("created") - }) + const unsubCreated = Bus.subscribe(SessionNs.Event.Created, (event) => { + if (event.properties.info.title === title) events.push("created") + }) - const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => { - events.push("updated") - }) + const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, (event) => { + if (event.properties.info.title === title) events.push("updated") + }) - const info = await create({}) - await new Promise((resolve) => setTimeout(resolve, 100)) - unsubCreated() - unsubUpdated() + const info = await create({ title }) + await new Promise((resolve) => setTimeout(resolve, 100)) + unsubCreated() + unsubUpdated() - expect(events).toContain("created") - expect(events).toContain("updated") - expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated")) + expect(events).toContain("created") + expect(events).toContain("updated") + expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated")) - await remove(info.id) - }, - }) + await remove(info.id) + Object.assign(flags, { experimentalWorkspaces: enabled }) + }, + }) + } finally { + if (previous === undefined) delete process.env.KILO_EXPERIMENTAL_WORKSPACES + else process.env.KILO_EXPERIMENTAL_WORKSPACES = previous + } }) }) diff --git a/packages/opencode/test/kilocode/task-nesting.test.ts b/packages/opencode/test/kilocode/task-nesting.test.ts index e8216daa5b..912e4b554e 100644 --- a/packages/opencode/test/kilocode/task-nesting.test.ts +++ b/packages/opencode/test/kilocode/task-nesting.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect, Exit, Layer } from "effect" import { Agent } from "../../src/agent/agent" +import { BackgroundJob } from "../../src/background/job" +import { Bus } from "../../src/bus" +import { SessionRunState } from "../../src/session/run-state" +import { SessionStatus } from "../../src/session/status" import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { Session } from "../../src/session/session" import { MessageV2 } from "../../src/session/message-v2" @@ -24,7 +29,12 @@ const ref = { const it = testEffect( Layer.mergeAll( Agent.defaultLayer, + BackgroundJob.defaultLayer, + Bus.defaultLayer, Config.defaultLayer, + RuntimeFlags.layer(), + SessionRunState.defaultLayer, + SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer, Truncate.defaultLayer, @@ -67,40 +77,42 @@ const seed = Effect.fn("NestedTaskToolTest.seed")(function* () { }) function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void }): TaskPromptOps { + const prompt = (input: SessionPrompt.PromptInput) => + Effect.sync(() => { + opts?.onPrompt?.(input) + const id = MessageID.ascending() + return { + info: { + id, + role: "assistant", + parentID: input.messageID ?? MessageID.ascending(), + sessionID: input.sessionID, + mode: input.agent ?? "general", + agent: input.agent ?? "general", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + finish: "stop", + }, + parts: [ + { + id: PartID.ascending(), + messageID: id, + sessionID: input.sessionID, + type: "text", + text: "done", + }, + ], + } satisfies MessageV2.WithParts + }) return { cancel: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), - prompt: (input) => - Effect.sync(() => { - opts?.onPrompt?.(input) - const id = MessageID.ascending() - return { - info: { - id, - role: "assistant", - parentID: input.messageID ?? MessageID.ascending(), - sessionID: input.sessionID, - mode: input.agent ?? "general", - agent: input.agent ?? "general", - cost: 0, - path: { cwd: "/tmp", root: "/tmp" }, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: ref.modelID, - providerID: ref.providerID, - time: { created: Date.now() }, - finish: "stop", - }, - parts: [ - { - id: PartID.ascending(), - messageID: id, - sessionID: input.sessionID, - type: "text", - text: "done", - }, - ], - } satisfies MessageV2.WithParts - }), + prompt, + loop: (input) => prompt({ sessionID: input.sessionID, parts: [] }), } } diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts index 58d6903397..1bf091152d 100644 --- a/packages/opencode/test/kilocode/tool-task-model.test.ts +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -3,7 +3,12 @@ import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" import { Agent } from "../../src/agent/agent" +import { BackgroundJob } from "../../src/background/job" +import { Bus } from "../../src/bus" +import { SessionRunState } from "../../src/session/run-state" +import { SessionStatus } from "../../src/session/status" import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { Global } from "@opencode-ai/core/global" import { Instance } from "../../src/project/instance" @@ -91,7 +96,12 @@ const catalog = { const it = testEffect( Layer.mergeAll( Agent.defaultLayer, + BackgroundJob.defaultLayer, + Bus.defaultLayer, Config.defaultLayer, + RuntimeFlags.layer(), + SessionRunState.defaultLayer, + SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer, Truncate.defaultLayer, @@ -130,14 +140,16 @@ const seed = Effect.fn("TaskToolModelTest.seed")(function* (title = "Parent") { }) function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { + const prompt = (input: SessionPrompt.PromptInput) => + Effect.sync(() => { + opts?.onPrompt?.(input) + return reply(input, opts?.text ?? "done") + }) return { cancel: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), - prompt: (input) => - Effect.sync(() => { - opts?.onPrompt?.(input) - return reply(input, opts?.text ?? "done") - }), + prompt, + loop: (input) => prompt({ sessionID: input.sessionID, parts: [] }), } } diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index 4ff27f19a5..8520afae21 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -17,6 +17,7 @@ import { TsCheck } from "../../src/kilocode/ts-check" // kilocode_change // kilocode_change - Typescript.spawn ignores ctx, so a cast is fine here. const fakeCtx = {} as InstanceContext +const fakeFlags = {} as RuntimeFlags.Info const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) const experimentalTyIt = testEffect( @@ -72,14 +73,22 @@ describe("lsp.spawn", () => { ), ) - // kilocode_change start - enable flag so spawn() is reached past the TsClient short-circuit - it.live("would spawn builtin LSP for files inside instance when lsp is true", () => + // kilocode_change start - provide the runtime flag so spawn() is reached past the TsClient short-circuit + const experimentalToolIt = testEffect( + Layer.mergeAll( + LSP.layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalLspTool: true })), + ), + CrossSpawnSpawner.defaultLayer, + ), + ) + + experimentalToolIt.live("would spawn builtin LSP for files inside instance when lsp is true", () => provideTmpdirInstance( (dir) => LSP.Service.use((lsp) => Effect.gen(function* () { - const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL - Flag.KILO_EXPERIMENTAL_LSP_TOOL = true const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) try { @@ -90,7 +99,6 @@ describe("lsp.spawn", () => { }) expect(spy).toHaveBeenCalledTimes(1) } finally { - Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved spy.mockRestore() } }), @@ -99,13 +107,11 @@ describe("lsp.spawn", () => { ), ) - it.live("would spawn builtin LSP for files inside instance when config object is provided", () => + experimentalToolIt.live("would spawn builtin LSP for files inside instance when config object is provided", () => provideTmpdirInstance( (dir) => LSP.Service.use((lsp) => Effect.gen(function* () { - const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL - Flag.KILO_EXPERIMENTAL_LSP_TOOL = true const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) try { @@ -116,7 +122,6 @@ describe("lsp.spawn", () => { }) expect(spy).toHaveBeenCalledTimes(1) } finally { - Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved spy.mockRestore() } }), @@ -147,7 +152,7 @@ describe("lsp.spawn", () => { await WithInstance.provide({ directory: tmp.path, fn: async () => { - const result = await LSPServer.Typescript.spawn(tmp.path, fakeCtx) + const result = await LSPServer.Typescript.spawn(tmp.path, fakeCtx, fakeFlags) expect(result).toBeDefined() expect(tsgoSpy).toHaveBeenCalledWith(tmp.path) expect(spawnSpy).toHaveBeenCalled() @@ -167,7 +172,7 @@ describe("lsp.spawn", () => { const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL Flag.KILO_EXPERIMENTAL_LSP_TOOL = false try { - const result = await LSPServer.Typescript.spawn("/tmp/any", fakeCtx) + const result = await LSPServer.Typescript.spawn("/tmp/any", fakeCtx, fakeFlags) expect(result).toBeUndefined() } finally { Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 1b09c36afd..a084a4664a 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -10,6 +10,8 @@ import { InstanceStore } from "../../src/project/instance-store" import { TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { Config } from "../../src/config/config" const bus = Bus.layer const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) @@ -18,8 +20,8 @@ const env = Layer.mergeAll( bus, CrossSpawnSpawner.defaultLayer, InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), -) -const it = testEffect(env) +).pipe(Layer.provide(RuntimeFlags.layer()), Layer.provide(Config.defaultLayer)) +const it = testEffect(Layer.mergeAll(env, RuntimeFlags.layer())) const rejectAll = (message?: string) => Effect.gen(function* () { @@ -874,7 +876,7 @@ it.instance( ) it.instance( - "reply - always keeps other session pending", + "reply - always resolves matching pending requests from other sessions", () => Effect.gen(function* () { const a = yield* ask({ @@ -901,10 +903,8 @@ it.instance( yield* reply({ requestID: PermissionID.make("per_test6a"), reply: "always" }) yield* Fiber.join(a) - expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test6b")]) - - yield* rejectAll() - yield* Fiber.await(b) + yield* Fiber.join(b) + expect(yield* list()).toHaveLength(0) }), { git: true }, ) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 5688d13d1a..f4b0e22fcb 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -113,8 +113,8 @@ describe("Project.fromDirectory", () => { expect(project.vcs).toBe("git") expect(project.worktree).toBe(tmp) - const opencodeFile = path.join(tmp, ".git", "opencode") - expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false) + const kiloFile = path.join(tmp, ".git", "kilo") + expect(yield* Effect.promise(() => Bun.file(kiloFile).exists())).toBe(false) }), ) @@ -129,8 +129,8 @@ describe("Project.fromDirectory", () => { expect(project.vcs).toBe("git") expect(project.worktree).toBe(tmp) - const opencodeFile = path.join(tmp, ".git", "opencode") - expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(true) + const kiloFile = path.join(tmp, ".git", "kilo") + expect(yield* Effect.promise(() => Bun.file(kiloFile).exists())).toBe(true) }), ) @@ -246,7 +246,7 @@ describe("Project.fromDirectory with worktrees", () => { expect(wt.id).toBe(main.id) // Cache should live in the common .git dir, not the worktree's .git file - const cache = path.join(tmp, ".git", "opencode") + const cache = path.join(tmp, ".git", "kilo") const exists = yield* Effect.promise(() => Bun.file(cache).exists()) expect(exists).toBe(true) }), @@ -644,8 +644,8 @@ describe("Project.fromDirectory with bare repos", () => { expect(project.id).not.toBe(ProjectID.global) expect(project.worktree).toBe(barePath) - const correctCache = path.join(barePath, "opencode") - const wrongCache = path.join(parentDir, ".git", "opencode") + const correctCache = path.join(barePath, "kilo") + const wrongCache = path.join(parentDir, ".git", "kilo") expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true) expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false) @@ -678,9 +678,9 @@ describe("Project.fromDirectory with bare repos", () => { expect(projA.id).not.toBe(projB.id) - const cacheA = path.join(bareA, "opencode") - const cacheB = path.join(bareB, "opencode") - const wrongCache = path.join(parentDir, ".git", "opencode") + const cacheA = path.join(bareA, "kilo") + const cacheB = path.join(bareB, "kilo") + const wrongCache = path.join(parentDir, ".git", "kilo") expect(yield* Effect.promise(() => Bun.file(cacheA).exists())).toBe(true) expect(yield* Effect.promise(() => Bun.file(cacheB).exists())).toBe(true) @@ -707,7 +707,7 @@ describe("Project.fromDirectory with bare repos", () => { expect(project.id).not.toBe(ProjectID.global) expect(project.worktree).toBe(barePath) - const correctCache = path.join(barePath, "opencode") + const correctCache = path.join(barePath, "kilo") expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true) }), ) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 1c6a8b3377..9cf1af1bb8 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2,9 +2,10 @@ import { test, expect } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { tmpdir } from "../fixture/fixture" import { Global } from "@opencode-ai/core/global" import { Instance } from "../../src/project/instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { WithInstance } from "../../src/project/with-instance" import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "@opencode-ai/core/models" @@ -83,7 +84,7 @@ async function markPluginDependenciesReady(dir: string) { function paid(providers: Awaited>) { const item = providers[ProviderID.make("opencode")] - expect(item).toBeDefined() + if (!item) return 0 // kilocode_change - Kilo drops opencode provider without apiKey/auth return Object.values(item.models).filter((model) => model.cost.input > 0).length } @@ -1891,9 +1892,9 @@ test("hosted nvidia provider adds billing origin header", async () => { fn: async () => { const providers = await list() expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({ - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", - "X-BILLING-INVOKE-ORIGIN": "OpenCode", + "HTTP-Referer": "https://kilo.ai/", + "X-Title": "Kilo Code", + "X-BILLING-INVOKE-ORIGIN": "KiloCode", }) }, }) @@ -1923,9 +1924,9 @@ test("custom nvidia baseURL adds billing origin header", async () => { fn: async () => { const providers = await list() expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({ - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", - "X-BILLING-INVOKE-ORIGIN": "OpenCode", + "HTTP-Referer": "https://kilo.ai/", + "X-Title": "Kilo Code", + "X-BILLING-INVOKE-ORIGIN": "KiloCode", }) }, }) @@ -2637,7 +2638,10 @@ test("plugin config providers persist after instance dispose", async () => { expect(first[ProviderID.make("demo")]).toBeDefined() expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() - await disposeAllInstances() + await WithInstance.provide({ + directory: tmp.path, + fn: () => InstanceRuntime.disposeInstance(Instance.current), + }) const second = await WithInstance.provide({ directory: tmp.path, @@ -2645,7 +2649,7 @@ test("plugin config providers persist after instance dispose", async () => { }) expect(second[ProviderID.make("demo")]).toBeDefined() expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() -}) +}, 60_000) test("plugin config enabled and disabled providers are honored", async () => { await using tmp = await tmpdir({ diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 3d838964fa..018b4bb773 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -59,7 +59,7 @@ describe("config HttpApi", () => { lsp: false, }) yield* Fiber.join(disposed) - expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "config.json")).json())).toMatchObject({ + expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "opencode.json")).json())).toMatchObject({ // kilocode_change username: "patched-user", formatter: false, lsp: false, diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 7aba411afb..17e10c22d9 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -294,7 +294,15 @@ describe("HttpApi Server.listen", () => { return true }) as typeof process.stderr.write try { - const response = await Server.Default().app.request("/status") + // kilocode_change start - use an authenticated local route instead of proxy-dependent status + Flag.KILO_SERVER_PASSWORD = auth.password + Flag.KILO_SERVER_USERNAME = auth.username + process.env.KILO_SERVER_PASSWORD = auth.password + process.env.KILO_SERVER_USERNAME = auth.username + const response = await Server.Default().app.request("/doc", { + headers: { authorization: authorization() }, + }) + // kilocode_change end expect(response.status).toBe(200) } finally { process.stderr.write = original diff --git a/packages/opencode/test/server/httpapi-mdns.test.ts b/packages/opencode/test/server/httpapi-mdns.test.ts index 934e7ec487..a0dd701ad9 100644 --- a/packages/opencode/test/server/httpapi-mdns.test.ts +++ b/packages/opencode/test/server/httpapi-mdns.test.ts @@ -62,7 +62,7 @@ describe("HttpApi Server.listen mDNS", () => { const published = events.filter((e) => e.kind === "publish") expect(published.length).toBe(1) expect(published[0]!.port).toBe(listener.port) - expect(published[0]!.name).toBe(`opencode-${listener.port}`) + expect(published[0]!.name).toBe(`kilo-${listener.port}`) // kilocode_change } finally { await withTimeout(listener.stop(true), 10_000, "timed out stopping mdns listener") } diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 6cf1af7ee0..e7790895c7 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -67,7 +67,7 @@ function app(input?: { password?: string; username?: string }) { ).handler return { request(input: string | URL | Request, init?: RequestInit) { - return Effect.promise(() => + return Effect.promise((): Promise => Promise.resolve( handler( input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), @@ -114,7 +114,7 @@ function uiApp(input?: { ).handler return { request(input: string | URL | Request, init?: RequestInit) { - return Effect.promise(() => + return Effect.promise((): Promise => Promise.resolve( handler( input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), @@ -156,7 +156,7 @@ function routeOrderingApp() { return { proxiedUrl: () => proxiedUrl, request(input: string | URL | Request, init?: RequestInit) { - return Effect.promise(() => + return Effect.promise((): Promise => Promise.resolve( handler( input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), @@ -195,7 +195,7 @@ describe("HttpApi UI fallback", () => { }).request("/") expect(response.status).toBe(404) - expect(yield* response.json).toEqual({ error: "Not Found" }) + expect(yield* Effect.promise(() => response.json())).toEqual({ error: "Not Found" }) expect(proxied).toBe(false) }), ) @@ -282,29 +282,47 @@ describe("HttpApi UI fallback", () => { it.live("accepts auth token for the web UI", () => Effect.gen(function* () { + let proxied = false // kilocode_change const response = yield* uiApp({ password: "secret", username: "kilo", // kilocode_change disableEmbeddedWebUi: true, - client: httpClient(new Response("kilo", { headers: { "content-type": "text/html" } })), + // kilocode_change start - authenticated requests still must not proxy when embedded UI is disabled + client: httpClient(new Response("kilo", { headers: { "content-type": "text/html" } }), () => { + proxied = true + }), + // kilocode_change end }).request(`/?auth_token=${btoa("kilo:secret")}`) - expect(response.status).toBe(200) - expect(yield* responseText(response)).toBe("kilo") + // kilocode_change start + expect(response.status).toBe(404) + expect(yield* Effect.promise(() => response.json())).toEqual({ error: "Not Found" }) + expect(proxied).toBe(false) + // kilocode_change end }), ) it.live("accepts basic auth for the web UI", () => Effect.gen(function* () { + let proxied = false // kilocode_change const response = yield* uiApp({ password: "secret", username: "kilo", // kilocode_change disableEmbeddedWebUi: true, + // kilocode_change start + client: httpClient(new Response("ui"), () => { + proxied = true + }), + // kilocode_change end }).request("/", { headers: { authorization: `Basic ${btoa("kilo:secret")}` }, }) - expect(response.status).toBe(200) + // kilocode_change start + expect(response.status).toBe(404) + expect(yield* Effect.promise(() => response.json())).toEqual({ error: "Not Found" }) + expect(proxied).toBe(false) + // kilocode_change end }), ) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index f734974f44..f43340e07a 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -28,6 +28,7 @@ import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestConfig } from "../fixture/config" import { SyncEvent } from "@/sync" +import { RuntimeFlags } from "@/effect/runtime-flags" void Log.init({ print: false }) @@ -211,9 +212,8 @@ function layer(result: "continue" | "compact") { } function cfg(compaction?: Config.Info["compaction"]) { - const base = Config.Info.zod.parse({}) return TestConfig.layer({ - get: () => Effect.succeed({ ...base, compaction }), + get: () => Effect.succeed({ compaction }), }) } @@ -225,17 +225,19 @@ const deps = Layer.mergeAll( Bus.layer, Config.defaultLayer, SyncEvent.defaultLayer, -) +).pipe(Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true }))) const env = Layer.mergeAll( SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer, SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)), -) +).pipe(Layer.provideMerge(Layer.mergeAll(RuntimeFlags.layer({ experimentalEventSystem: true }), Config.defaultLayer))) const it = testEffect(env) -const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer) +const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe( + Layer.provideMerge(Layer.mergeAll(RuntimeFlags.layer({ experimentalEventSystem: true }), Config.defaultLayer)), +) const itCompaction = testEffect(compactionEnv) type CompactionProcessOptions = { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 7d53395556..82836741ce 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -48,6 +48,7 @@ import { Truncate } from "@/tool/truncate" import * as Log from "@opencode-ai/core/util/log" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import * as Database from "../../src/storage/db" +import { Storage } from "../../src/storage/storage" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" import { Reference } from "../../src/reference/reference" @@ -55,6 +56,8 @@ import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" import { SyncEvent } from "@/sync" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { BackgroundJob } from "@/background/job" void Log.init({ print: false }) const summary = Layer.succeed( @@ -178,6 +181,7 @@ const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLaye function makeHttp() { const deps = Layer.mergeAll( Session.defaultLayer, + BackgroundJob.defaultLayer, Snapshot.defaultLayer, LLM.defaultLayer, Env.defaultLayer, @@ -192,7 +196,7 @@ function makeHttp() { AppFileSystem.defaultLayer, status, SyncEvent.defaultLayer, - ).pipe(Layer.provideMerge(infra)) + ).pipe(Layer.provideMerge(infra), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true }))) const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const registry = ToolRegistry.layer.pipe( @@ -216,6 +220,7 @@ function makeHttp() { const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, + BackgroundJob.defaultLayer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), Layer.provide(Image.defaultLayer), @@ -230,7 +235,21 @@ function makeHttp() { Layer.provide(SystemPrompt.defaultLayer), Layer.provideMerge(deps), ), - ).pipe(Layer.provide(summary)) + ).pipe( + Layer.provide( + Layer.mergeAll( + summary, + deps, + Config.defaultLayer, + RuntimeFlags.layer({ experimentalEventSystem: true }), + BackgroundJob.defaultLayer, + Bus.layer, + infra, + Storage.defaultLayer, + Reference.defaultLayer, + ), + ), + ) } const it = testEffect(makeHttp()) @@ -1132,9 +1151,13 @@ it.live( yield* sessions.updateMessage(childAssistant) yield* ctx.metadata({ title: "done", - metadata: { sessionId: child.id, model: ref, variant: undefined }, + metadata: { parentSessionId: ctx.sessionID, sessionId: child.id, model: ref, variant: undefined }, }) - return { title: "done", metadata: { sessionId: child.id, model: ref, variant: undefined }, output: "done" } + return { + title: "done", + metadata: { parentSessionId: ctx.sessionID, sessionId: child.id, model: ref, variant: undefined }, + output: "done", + } }) yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) @@ -2164,7 +2187,7 @@ it.live("keeps stored part order stable when file resolution is async", () => if (msg.info.role !== "user") throw new Error("expected user message") - const stored = MessageV2.get({ + const stored = yield* MessageV2.get({ sessionID: session.id, messageID: msg.info.id, }) @@ -2206,7 +2229,7 @@ it.live("handles filenames with # character", () => parts, noReply: true, }) - const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) const textParts = stored.parts.filter((part) => part.type === "text") const hasContent = textParts.some((part) => part.text.includes("special content")) expect(hasContent).toBe(true) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 88b6174754..9ecd9ba72f 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" -import { Effect, Layer, Schedule } from "effect" +import { Effect, Layer, Schedule, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -17,7 +17,7 @@ const retryProvider = "test" const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) function apiError(headers?: Record): MessageV2.APIError { - return MessageV2.APIError.Schema.parse( + return Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "boom", isRetryable: true, @@ -94,7 +94,7 @@ describe("session.retry.delay", () => { const step = yield* Schedule.toStepWithMetadata( SessionRetry.policy({ provider: "test", - parse: (err) => MessageV2.APIError.Schema.parse(err), + parse: (err) => Schema.decodeUnknownSync(MessageV2.APIError.Schema)(err), set: (info) => status.set(sessionID, { type: "retry", @@ -173,7 +173,7 @@ describe("session.retry.retryable", () => { }) test("retries 500 errors even when isRetryable is false", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Internal server error", isRetryable: false, @@ -186,7 +186,7 @@ describe("session.retry.retryable", () => { }) test("retries 502 bad gateway errors", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Bad gateway", isRetryable: false, @@ -198,7 +198,7 @@ describe("session.retry.retryable", () => { }) test("retries 503 service unavailable errors", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Service unavailable", isRetryable: false, @@ -210,7 +210,7 @@ describe("session.retry.retryable", () => { }) test("does not retry 4xx errors when isRetryable is false", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Bad request", isRetryable: false, @@ -222,7 +222,7 @@ describe("session.retry.retryable", () => { }) test("retries ZlibError decompression failures", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Response decompression failed", isRetryable: true, @@ -237,7 +237,7 @@ describe("session.retry.retryable", () => { // kilocode_change start - Kilo does not support OpenCode Go upsells test("does not retry free usage limits", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Free usage exceeded", isRetryable: true, @@ -293,7 +293,7 @@ describe("session.message-v2.fromError", () => { ) test("ECONNRESET socket error is retryable", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Connection reset by server", isRetryable: true, diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 89bcb37045..4bcf8d54e5 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect } from "bun:test" +import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" import { Discovery } from "../../src/skill/discovery" @@ -6,7 +6,6 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Flag } from "@opencode-ai/core/flag/flag" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Global } from "@opencode-ai/core/global" import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture" @@ -16,20 +15,18 @@ import fs from "fs/promises" const node = CrossSpawnSpawner.defaultLayer -const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node)) -const itWithoutClaudeCodeSkills = testEffect( - Layer.mergeAll( - Skill.layer.pipe( - Layer.provide(Discovery.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })), - ), - node, - ), -) +const skills = (disableClaudeCodeSkills: boolean) => + Skill.layer.pipe( + Layer.provide(Discovery.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.layer), + Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills })), + ) + +const it = testEffect(Layer.mergeAll(skills(false), node)) +const itWithoutClaudeCodeSkills = testEffect(Layer.mergeAll(skills(true), node)) async function createGlobalSkill(homeDir: string) { const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") @@ -63,17 +60,7 @@ const withHome = (home: string, self: Effect.Effect) => ) const discovered = (list: readonly T[]) => - list.filter((s) => s.location !== Skill.BUILTIN_LOCATION) // kilocode_change - -const disabled = Flag.KILO_DISABLE_CLAUDE_CODE_SKILLS - -beforeAll(() => { - Flag.KILO_DISABLE_CLAUDE_CODE_SKILLS = false -}) - -afterAll(() => { - Flag.KILO_DISABLE_CLAUDE_CODE_SKILLS = disabled -}) + list.filter((skill) => ![Skill.BUILTIN_LOCATION, ""].includes(skill.location)) // kilocode_change describe("skill", () => { // kilocode_change start @@ -431,7 +418,7 @@ description: A skill in the .agents/skills directory. ) const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") + const list = discovered(yield* skill.all()) // kilocode_change expect(list.map((s) => s.name)).toEqual(["agent-skill"]) }), { git: true }, diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 3239855ca5..ec772e1ee7 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -33,42 +33,55 @@ import { ProviderID, ModelID } from "@/provider/schema" import { ToolJsonSchema } from "@/tool/json-schema" import { MessageID, SessionID } from "@/session/schema" import { RuntimeFlags } from "@/effect/runtime-flags" +import { Command } from "@/command" const node = CrossSpawnSpawner.defaultLayer const configLayer = TestConfig.layer({ directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), }) -const registryLayer = (flags: Partial = {}) => - ToolRegistry.layer - .pipe( - Layer.provide(configLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)), - Layer.provide(Provider.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Reference.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Format.defaultLayer), - Layer.provide(node), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Truncate.defaultLayer), - ) - .pipe(Layer.provide(RuntimeFlags.layer(flags))) +const registryLayer = (flags: Partial = {}) => { + const deps = Layer.mergeAll( + configLayer, + Plugin.defaultLayer, + Question.defaultLayer, + Todo.defaultLayer, + Skill.defaultLayer, + Agent.defaultLayer, + Session.defaultLayer, + Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer), + Provider.defaultLayer, + Git.defaultLayer, + Reference.defaultLayer, + LSP.defaultLayer, + Instruction.defaultLayer, + Command.defaultLayer, + AppFileSystem.defaultLayer, + Bus.layer, + FetchHttpClient.layer, + Format.defaultLayer, + node, + Layer.mergeAll(Ripgrep.defaultLayer, Truncate.defaultLayer), + ) + return ToolRegistry.layer.pipe( + Layer.provide(deps), + Layer.provide(RuntimeFlags.layer(flags)), + Layer.provide(node), + Layer.provide(Agent.defaultLayer), + ) +} -const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer)) -const scout = testEffect(Layer.mergeAll(registryLayer({ experimentalScout: true }), node, Agent.defaultLayer)) +const it = testEffect(Layer.mergeAll(registryLayer(), Agent.defaultLayer, RuntimeFlags.layer(), configLayer)) +const scout = testEffect( + Layer.mergeAll(registryLayer({ experimentalScout: true }), Agent.defaultLayer, RuntimeFlags.layer(), configLayer), +) const background = testEffect( - Layer.mergeAll(registryLayer({ experimentalBackgroundSubagents: true }), node, Agent.defaultLayer), + Layer.mergeAll( + registryLayer({ experimentalBackgroundSubagents: true }), + Agent.defaultLayer, + RuntimeFlags.layer(), + configLayer, + ), ) afterEach(async () => { diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 0bcdd85f3c..32e9d18d62 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -654,7 +654,7 @@ describe("tool.task", () => { ) yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 }) - const parent = MessageV2.get({ sessionID: chat.id, messageID: assistant.id }) + const parent = yield* MessageV2.get({ sessionID: chat.id, messageID: assistant.id }) expect(parent.info.role === "assistant" ? parent.info.cost : 0).toBeCloseTo(0.2, 6) }), ) @@ -933,7 +933,7 @@ describe("tool.task cost propagation", () => { ask: () => Effect.void, }, ) - const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + const parent = yield* MessageV2.get({ sessionID: chat.id, messageID: assistant.id }) expect(parent.info.role).toBe("assistant") if (parent.info.role !== "assistant") return expect(parent.info.cost).toBeCloseTo(0.25, 6) @@ -986,7 +986,7 @@ describe("tool.task cost propagation", () => { ask: () => Effect.void, }, ) - const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + const parent = yield* MessageV2.get({ sessionID: chat.id, messageID: assistant.id }) if (parent.info.role !== "assistant") return // Only the delta since the start of this invocation propagates. expect(parent.info.cost).toBeCloseTo(0.15, 6) @@ -1039,7 +1039,7 @@ describe("tool.task cost propagation", () => { ask: () => Effect.void, }, ) - const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + const parent = yield* MessageV2.get({ sessionID: chat.id, messageID: assistant.id }) if (parent.info.role !== "assistant") return // Delta-only: only the 0.05 from this run, not 0.15 including the pre-existing 0.10. expect(parent.info.cost).toBeCloseTo(0.05, 6) @@ -1102,7 +1102,7 @@ describe("tool.task cost propagation", () => { ) .pipe(Effect.exit) - const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + const parent = yield* MessageV2.get({ sessionID: chat.id, messageID: assistant.id }) if (parent.info.role !== "assistant") return expect(parent.info.cost).toBeCloseTo(0.07, 6) }), diff --git a/packages/opencode/test/util/log.test.ts b/packages/opencode/test/util/log.test.ts index 62dc1d61c2..da42c6d022 100644 --- a/packages/opencode/test/util/log.test.ts +++ b/packages/opencode/test/util/log.test.ts @@ -51,22 +51,22 @@ it.live("init cleanup keeps the newest timestamped logs", () => it.live("local dev log is not truncated twice for the same run", () => Effect.gen(function* () { const log = Global.Path.log - const runID = process.env.OPENCODE_RUN_ID - const initialized = process.env.OPENCODE_LOG_INITIALIZED_RUN_ID + const runID = process.env.KILO_RUN_ID + const initialized = process.env.KILO_LOG_INITIALIZED_RUN_ID yield* Effect.addFinalizer(() => Effect.sync(() => { Global.Path.log = log - if (runID === undefined) delete process.env.OPENCODE_RUN_ID - else process.env.OPENCODE_RUN_ID = runID - if (initialized === undefined) delete process.env.OPENCODE_LOG_INITIALIZED_RUN_ID - else process.env.OPENCODE_LOG_INITIALIZED_RUN_ID = initialized + if (runID === undefined) delete process.env.KILO_RUN_ID + else process.env.KILO_RUN_ID = runID + if (initialized === undefined) delete process.env.KILO_LOG_INITIALIZED_RUN_ID + else process.env.KILO_LOG_INITIALIZED_RUN_ID = initialized }), ) const dir = yield* tmpdirScoped() Global.Path.log = dir - process.env.OPENCODE_RUN_ID = "run-1" - delete process.env.OPENCODE_LOG_INITIALIZED_RUN_ID + process.env.KILO_RUN_ID = "run-1" + delete process.env.KILO_LOG_INITIALIZED_RUN_ID yield* Effect.promise(() => Log.init({ print: false, dev: true })) yield* Effect.promise(() => fs.writeFile(path.join(dir, "dev.log"), "main startup\n")) diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index 6a978b0960..72f20e02fe 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -20,6 +20,7 @@ export type DiffText = { before: string after: string patch: string + patchIsPartial: boolean } export type ViewDiff = { diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts index 44e0742f0c..01d5f29460 100644 --- a/script/check-opencode-promise-facades.ts +++ b/script/check-opencode-promise-facades.ts @@ -27,30 +27,19 @@ const allow: Record = { "cli/cmd/tui/config/tui.ts": "separately tracked TUI config facade", "installation/index.ts": "existing installation facade outside #10655", "session/compaction.ts": "existing compaction facade outside #10655", - "sync/index.ts": "sync event runtime boundary", } const testAllow: Record = { - "config/agent-color.test.ts": { count: 2, reason: "existing runtime integration test" }, - "config/tui.test.ts": { count: 3, reason: "existing runtime integration test" }, - "control-plane/workspace.test.ts": { count: 11, reason: "existing runtime integration test" }, - "effect/app-runtime-logger.test.ts": { count: 6, reason: "tests AppRuntime behavior" }, + "control-plane/workspace.test.ts": { count: 3, reason: "existing runtime integration test" }, "kilocode/config-resilience.test.ts": { count: 4, reason: "existing runtime integration test" }, "kilocode/config-validation.test.ts": { count: 2, reason: "existing runtime integration test" }, "kilocode/plan-followup.test.ts": { count: 4, reason: "existing runtime integration test" }, "kilocode/server/config-overlay.test.ts": { count: 3, reason: "server config cache integration test" }, "kilocode/session/platform-attribution.test.ts": { count: 5, reason: "existing runtime integration test" }, "kilocode/session-prompt-queue.test.ts": { count: 5, reason: "prompt queue legacy instance bridge regression" }, - "kilocode/session/session.test.ts": { count: 4, reason: "existing runtime integration test" }, - "mcp/headers.test.ts": { count: 4, reason: "existing runtime integration test" }, - "mcp/oauth-browser.test.ts": { count: 4, reason: "existing runtime integration test" }, - "permission-task.test.ts": { count: 2, reason: "existing runtime integration test" }, - "project/vcs.test.ts": { count: 14, reason: "existing runtime integration test" }, + "kilocode/session/session.test.ts": { count: 5, reason: "existing runtime integration test" }, "provider/amazon-bedrock.test.ts": { count: 2, reason: "existing runtime integration test" }, "provider/provider.test.ts": { count: 3, reason: "existing runtime integration test" }, - "pty/pty-output-isolation.test.ts": { count: 4, reason: "existing runtime integration test" }, - "pty/pty-session.test.ts": { count: 3, reason: "existing runtime integration test" }, - "pty/pty-shell.test.ts": { count: 4, reason: "existing runtime integration test" }, "session/llm.test.ts": { count: 2, reason: "existing runtime integration test" }, "tool/recall.test.ts": { count: 10, reason: "existing runtime integration test" }, }