From 1872dbc25b84fe98118261f9b93e66a276ff51ea Mon Sep 17 00:00:00 2001 From: Johnny Amancio Date: Thu, 16 Jul 2026 15:32:19 +0200 Subject: [PATCH] fix(opencode): address fourth-pass upstream review --- packages/opencode/src/agent/agent.ts | 5 + packages/opencode/src/kilocode/reference.ts | 19 +- .../kilocode/server/reference-reconciler.ts | 55 +- .../server/routes/instance/httpapi/server.ts | 9 +- packages/opencode/src/session/system.ts | 17 +- .../opencode/test/kilocode/reference.test.ts | 68 +++ .../opencode/test/kilocode/tui/signal.test.ts | 21 + .../test/kilocode/tui/sync-v2.test.tsx | 568 ++++++++++++++++++ packages/opencode/test/session/system.test.ts | 2 + packages/server/src/handlers.ts | 3 +- .../src/kilocode/reference-reconciler.ts | 11 +- packages/server/src/routes.ts | 2 + packages/tui/src/component/kilo-logo.tsx | 2 +- 13 files changed, 750 insertions(+), 32 deletions(-) create mode 100644 packages/opencode/test/kilocode/tui/sync-v2.test.tsx diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ac8915e2fa..085eca0637 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -125,6 +125,11 @@ export const layer = Layer.effect( // kilocode_change start - include global config dirs so agents can read them without prompting const referenceDirs = yield* Effect.gen(function* () { yield* (yield* PluginBoot.Service).wait() + yield* KiloReference.sync({ + references: cfg.references ?? cfg.reference ?? {}, + directory: ctx.directory, + worktree: ctx.worktree, + }) return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) const whitelistedDirs = [ diff --git a/packages/opencode/src/kilocode/reference.ts b/packages/opencode/src/kilocode/reference.ts index fba9068238..5bb088e7ab 100644 --- a/packages/opencode/src/kilocode/reference.ts +++ b/packages/opencode/src/kilocode/reference.ts @@ -131,6 +131,22 @@ export function ensure(cache: RepositoryCache.Interface, item: Extract((item) => { if (item.kind === "invalid") return [] if (item.kind === "local") { @@ -170,5 +185,7 @@ export const sync = Effect.fn("KiloReference.sync")(function* (input: { ] as const, ] }) + const current = new Map((yield* service.list()).map((item) => [item.name, item.source])) + if (current.size === sources.length && sources.every(([name, source]) => same(current.get(name), source))) return yield* service.replace(sources) }) diff --git a/packages/opencode/src/kilocode/server/reference-reconciler.ts b/packages/opencode/src/kilocode/server/reference-reconciler.ts index 9dd5b6e0c6..2d2c7a0e34 100644 --- a/packages/opencode/src/kilocode/server/reference-reconciler.ts +++ b/packages/opencode/src/kilocode/server/reference-reconciler.ts @@ -3,30 +3,47 @@ import { InstanceRef } from "@/effect/instance-ref" import { isInterrupted } from "@/kilocode/effect/cause" import * as KiloReference from "@/kilocode/reference" import { InstanceStore } from "@/project/instance-store" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Location } from "@opencode-ai/core/location" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { ReferenceReconciler } from "@opencode-ai/server/kilocode/reference-reconciler" import { Effect, Layer } from "effect" -export const layer = Layer.effect( - ReferenceReconciler, +const reconcile = Effect.gen(function* () { + const config = yield* Config.Service + const store = yield* InstanceStore.Service + return Effect.gen(function* () { + const location = yield* Location.Service + const ctx = yield* store.load({ directory: location.directory }) + const cfg = yield* config.get().pipe(Effect.provideService(InstanceRef, ctx)) + yield* PluginBoot.Service.use((boot) => boot.wait()) + yield* KiloReference.sync({ + references: cfg.references ?? cfg.reference ?? {}, + directory: ctx.directory, + worktree: ctx.worktree, + }).pipe( + Effect.catchCause((cause) => + isInterrupted(cause) ? Effect.interrupt : Effect.logWarning("reference sync failed", { cause }), + ), + ) + }) +}) + +export const layer = Layer.effect(ReferenceReconciler, reconcile) +export const locations = Layer.effect( + LocationServiceMap, Effect.gen(function* () { - const config = yield* Config.Service - const store = yield* InstanceStore.Service - return Effect.gen(function* () { - const location = yield* Location.Service - const ctx = yield* store.load({ directory: location.directory }) - const cfg = yield* config.get().pipe(Effect.provideService(InstanceRef, ctx)) - yield* PluginBoot.Service.use((boot) => boot.wait()) - yield* KiloReference.sync({ - references: cfg.references ?? cfg.reference ?? {}, - directory: ctx.directory, - worktree: ctx.worktree, - }).pipe( - Effect.catchCause((cause) => - isInterrupted(cause) ? Effect.interrupt : Effect.logWarning("reference sync failed", { cause }), - ), - ) + const locations = yield* LocationServiceMap + const initialize = yield* reconcile + return LocationServiceMap.of({ + ...locations, + get: (ref) => Layer.effectDiscard(initialize).pipe(Layer.provideMerge(locations.get(ref))), + contextEffect: (ref) => + Effect.gen(function* () { + const context = yield* locations.contextEffect(ref) + yield* initialize.pipe(Effect.provide(context)) + return context + }), }) }), -) +).pipe(Layer.provide(LocationServiceMap.layer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 94b5bcab28..e2a6d27bba 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -102,7 +102,10 @@ import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" import { handlers } from "@opencode-ai/server/handlers" -import { layer as referenceReconcilerLayer } from "@/kilocode/server/reference-reconciler" // kilocode_change +import { + layer as referenceReconcilerLayer, + locations as locationServiceMapLayer, +} from "@/kilocode/server/reference-reconciler" // kilocode_change import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error" import { workspaceHandlers } from "./handlers/workspace" // kilocode_change start @@ -181,7 +184,9 @@ const instanceRoutes = instanceApiRoutes.pipe( Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]), ) const serverRoutes = HttpApiBuilder.layer(Api).pipe( - Layer.provide(handlers.pipe(Layer.provide(referenceReconcilerLayer))), // kilocode_change + // kilocode_change start - effective references must be ready before any V2 location consumer runs + Layer.provide(handlers.pipe(Layer.provide(locationServiceMapLayer), Layer.provide(referenceReconcilerLayer))), + // kilocode_change end Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]), ) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index bf4b679fc4..7922e84330 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -29,6 +29,8 @@ import SOUL from "../kilocode/soul.txt" import type { EditorContext } from "../kilocode/editor-context" import { KilocodeSystemPrompt } from "../kilocode/system-prompt" import { isLing } from "../kilocode/model-match" +import { Config } from "@/config/config" +import * as KiloReference from "@/kilocode/reference" // kilocode_change end // kilocode_change start @@ -97,6 +99,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const skill = yield* Skill.Service const locations = yield* LocationServiceMap + const config = yield* Config.Service // kilocode_change return Service.of({ // kilocode_change start @@ -105,8 +108,14 @@ export const layer = Layer.effect( editorContext?: EditorContext, ) { const ctx = yield* InstanceState.context + const cfg = yield* config.get() const references = yield* Effect.gen(function* () { yield* (yield* PluginBoot.Service).wait() + yield* KiloReference.sync({ + references: cfg.references ?? cfg.reference ?? {}, + directory: ctx.directory, + worktree: ctx.worktree, + }) return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) return [ @@ -150,10 +159,14 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer), Layer.provide(LocationServiceMap.layer)) +export const defaultLayer = layer.pipe( + Layer.provide(Skill.defaultLayer), + Layer.provide(LocationServiceMap.layer), + Layer.provide(Config.defaultLayer), // kilocode_change +) const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, []) -export const node = LayerNode.make(layer, [Skill.node, locationServiceMapNode]) +export const node = LayerNode.make(layer, [Skill.node, locationServiceMapNode, Config.node]) // kilocode_change export * as SystemPrompt from "./system" diff --git a/packages/opencode/test/kilocode/reference.test.ts b/packages/opencode/test/kilocode/reference.test.ts index 7f27c3b371..efddb1ef38 100644 --- a/packages/opencode/test/kilocode/reference.test.ts +++ b/packages/opencode/test/kilocode/reference.test.ts @@ -6,6 +6,12 @@ import * as Reference from "../../src/kilocode/reference" import { Reference as CoreReference } from "@opencode-ai/core/reference" import { EventV2 } from "@opencode-ai/core/event" import { Global } from "@opencode-ai/core/global" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Config } from "../../src/config/config" +import { locations } from "../../src/kilocode/server/reference-reconciler" +import { testInstanceStoreLayer, tmpdir } from "../fixture/fixture" function remote() { const item = Reference.resolveAll({ @@ -67,4 +73,66 @@ describe("configured references", () => { }), ]) }) + + test("sync does not publish an update for equivalent references", async () => { + const cache = Layer.mock(RepositoryCache.Service, { + ensure: () => Effect.die("unexpected Git materialization"), + }) + const updates: string[] = [] + const events = Layer.mock(EventV2.Service)({ + publish: (definition, data) => { + updates.push(definition.type) + return Effect.succeed({ id: EventV2.ID.make(`evt_${updates.length}`), type: definition.type, data }) + }, + }) + const layer = CoreReference.layer.pipe( + Layer.provide(cache), + Layer.provide(events), + Layer.provide(Global.defaultLayer), + ) + const input = { + references: { docs: { path: "./docs", description: "Internal documentation", hidden: true } }, + directory: "/workspace/src", + worktree: "/workspace", + } + + await Effect.runPromise( + Effect.gen(function* () { + yield* Reference.sync(input) + yield* Reference.sync(input) + }).pipe(Effect.provide(layer), Effect.scoped), + ) + + expect(updates).toEqual(["reference.updated"]) + }) + + test("initializes effective references before exposing location services", async () => { + await using tmp = await tmpdir({ + config: { + formatter: false, + lsp: false, + references: { + docs: { path: "./docs", description: "Internal documentation" }, + }, + }, + }) + const layer = locations.pipe(Layer.provide(Config.defaultLayer), Layer.provide(testInstanceStoreLayer)) + + const result = await Effect.runPromise( + Effect.gen(function* () { + const map = yield* LocationServiceMap + return yield* CoreReference.Service.use((reference) => reference.list()).pipe( + Effect.provide(map.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))), + ) + }).pipe(Effect.provide(layer), Effect.scoped), + ) + + expect(result).toEqual([ + expect.objectContaining({ + name: "docs", + path: path.join(tmp.path, "docs"), + description: "Internal documentation", + }), + ]) + }, 15_000) }) diff --git a/packages/opencode/test/kilocode/tui/signal.test.ts b/packages/opencode/test/kilocode/tui/signal.test.ts index 4fe09db430..bcb5a8d11d 100644 --- a/packages/opencode/test/kilocode/tui/signal.test.ts +++ b/packages/opencode/test/kilocode/tui/signal.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { createRoot } from "solid-js" +import { createLeadingTrailingSignal } from "@/kilocode/plugins/session-switcher/preview-pane" import { createDebouncedSignal } from "@tui/util/signal" describe("TUI scheduling", () => { @@ -16,4 +17,24 @@ describe("TUI scheduling", () => { dispose() }) }) + + test("updates on the leading and trailing edges", async () => { + await createRoot(async (dispose) => { + const [value, , schedule] = createLeadingTrailingSignal("initial", 10) + + schedule("leading") + expect(value()).toBe("leading") + + schedule("middle") + schedule("trailing") + expect(value()).toBe("leading") + + await Bun.sleep(30) + expect(value()).toBe("trailing") + + schedule("next") + expect(value()).toBe("next") + dispose() + }) + }) }) diff --git a/packages/opencode/test/kilocode/tui/sync-v2.test.tsx b/packages/opencode/test/kilocode/tui/sync-v2.test.tsx new file mode 100644 index 0000000000..64503fa838 --- /dev/null +++ b/packages/opencode/test/kilocode/tui/sync-v2.test.tsx @@ -0,0 +1,568 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import type { Event, GlobalEvent } from "@kilocode/sdk/v2" +import { onMount } from "solid-js" +import { ProjectProvider, useProject } from "@tui/context/project" +import { SDKProvider } from "@tui/context/sdk" +import { SyncProviderV2, useSyncV2 } from "@/kilocode/plugins/sync-v2" +import { createEventSource, createFetch, directory, json } from "../../../../tui/test/fixture/tui-sdk" + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +// kilocode_change start - live events are filtered by the resolved project ID +function synced(ready: () => void) { + const project = useProject() + onMount(async () => { + await project.sync() + ready() + }) +} +// kilocode_change end + +function global(payload: Event): GlobalEvent { + return { directory, project: "proj_test", payload } +} + +function emitTwice(events: ReturnType, payload: Event) { + const event = global(payload) + events.emit(event) + events.emit(event) +} + +test("sync v2 settles pending tools when a live failure arrives", async () => { + const events = createEventSource() + const calls = createFetch() + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + synced(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + emitTwice(events, { + id: "evt_agent_1", + type: "session.next.agent.switched", + properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, + }) + emitTwice(events, { + id: "evt_model_1", + type: "session.next.model.switched", + properties: { + sessionID: "session-1", + messageID: "msg_model_1", + timestamp: 0, + model: { id: "model-1", providerID: "provider-1" }, + }, + }) + emitTwice(events, { + id: "evt_step_started_1", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_explicit_assistant_9", + timestamp: 1, + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + }, + }) + emitTwice(events, { + id: "evt_input_1", + type: "session.next.tool.input.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_explicit_assistant_9", + timestamp: 2, + callID: "call-1", + name: "bash", + }, + }) + emitTwice(events, { + id: "evt_called_1", + type: "session.next.tool.called", + properties: { + sessionID: "session-1", + timestamp: 2, + assistantMessageID: "msg_explicit_assistant_9", + callID: "call-1", + tool: "bash", + input: {}, + provider: { executed: false, metadata: { fake: { call: true } } }, + }, + }) + emitTwice(events, { + id: "evt_failed_1", + type: "session.next.tool.failed", + properties: { + sessionID: "session-1", + timestamp: 3, + assistantMessageID: "msg_explicit_assistant_9", + callID: "call-1", + error: { type: "unknown", message: "aborted" }, + provider: { executed: false, metadata: { fake: { result: true } } }, + }, + }) + + await wait(() => { + const assistant = sync.session.message.fromSession("session-1")[0] + return ( + assistant?.type === "assistant" && + assistant.content[0]?.type === "tool" && + assistant.content[0].state.status === "error" + ) + }) + + const assistant = sync.session.message.fromSession("session-1")[0] + expect(assistant?.type).toBe("assistant") + if (assistant?.type !== "assistant") return + expect(assistant.id).toBe("msg_explicit_assistant_9") + const tool = assistant.content[0] + expect(tool?.type).toBe("tool") + if (tool?.type !== "tool") return + expect(tool.state.status).toBe("error") + if (tool.state.status !== "error") return + expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" }) + expect(tool.state.input).toEqual({}) + expect(tool.state.structured).toEqual({}) + expect(tool.state.content).toEqual([]) + expect(tool.provider).toEqual({ + executed: false, + metadata: { fake: { call: true } }, + resultMetadata: { fake: { result: true } }, + }) + expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([ + "assistant", + "model-switched", + "agent-switched", + ]) + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 renders admitted prompts only after promotion", async () => { + const events = createEventSource() + const calls = createFetch() + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + synced(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + emitTwice(events, { + id: "evt_admitted_1", + type: "session.next.prompt.admitted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 0, + prompt: { text: "hello" }, + delivery: "steer", + }, + }) + expect(sync.session.message.fromSession("session-1")).toEqual([]) + + emitTwice(events, { + id: "evt_promoted_1", + type: "session.next.prompt.promoted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 1, + prompt: { text: "hello" }, + timeCreated: 0, + }, + }) + + await wait(() => sync.session.message.fromSession("session-1").length === 1) + const message = sync.session.message.fromSession("session-1")[0] + expect(message?.type).toBe("user") + if (message?.type !== "user") return + expect(message).toMatchObject({ id: "msg_user_1", text: "hello" }) + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 renders a promoted prompt when admission was missed", async () => { + const events = createEventSource() + const calls = createFetch() + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + synced(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + emitTwice(events, { + id: "evt_promoted_1", + type: "session.next.prompt.promoted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 1, + prompt: { text: "hello" }, + timeCreated: 0, + }, + }) + + await wait(() => sync.session.message.fromSession("session-1").length === 1) + expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1") + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 projects live context updates with their message ID", async () => { + const events = createEventSource() + const calls = createFetch() + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + synced(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + emitTwice(events, { + id: "evt_context_1", + type: "session.next.context.updated", + properties: { + sessionID: "session-1", + messageID: "msg_context_1", + timestamp: 1, + text: "Updated context", + }, + }) + + await wait(() => sync.session.message.fromSession("session-1").length === 1) + expect(sync.session.message.fromSession("session-1")[0]).toMatchObject({ + id: "msg_context_1", + type: "system", + text: "Updated context", + }) + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 preserves live events while snapshot hydration is in flight", async () => { + const events = createEventSource() + const response = Promise.withResolvers() + const calls = createFetch((url) => { + if (url.pathname === "/api/session/session-1/message") return response.promise + return undefined + }) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + synced(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + const hydration = sync.session.message.sync("session-1") + emitTwice(events, { + id: "evt_agent_1", + type: "session.next.agent.switched", + properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, + }) + response.resolve(json({ data: [] })) + await hydration + + expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([ + ["msg_agent_1", "agent-switched"], + ]) + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => { + const events = createEventSource() + const response = Promise.withResolvers() + const calls = createFetch((url) => { + if (url.pathname === "/api/session/session-1/message") return response.promise + return undefined + }) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + synced(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + emitTwice(events, { + id: "evt_promoted_1", + type: "session.next.prompt.promoted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 1, + prompt: { text: "stale" }, + timeCreated: 0, + }, + }) + await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1") + const hydration = sync.session.message.sync("session-1") + emitTwice(events, { + id: "evt_agent_1", + type: "session.next.agent.switched", + properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" }, + }) + await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1") + response.resolve( + json({ + data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }], + }), + ) + await hydration + + expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([ + ["msg_agent_1", "agent-switched"], + ["msg_user_1", "user"], + ]) + expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" }) + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => { + const events = createEventSource() + const response = Promise.withResolvers() + const calls = createFetch((url) => { + if (url.pathname === "/api/session/session-1/message") return response.promise + return undefined + }) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + synced(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + emitTwice(events, { + id: "evt_step_older", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_older", + timestamp: 0, + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + emitTwice(events, { + id: "evt_step_1", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_old", + timestamp: 1, + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old") + const hydration = sync.session.message.sync("session-1") + emitTwice(events, { + id: "evt_text_1", + type: "session.next.text.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_old", + timestamp: 2, + textID: "text-1", + }, + }) + emitTwice(events, { + id: "evt_text_older", + type: "session.next.text.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_older", + timestamp: 2, + textID: "text-older", + }, + }) + await wait(() => { + const messages = sync.session.message.fromSession("session-1") + return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text") + }) + response.resolve( + json({ + data: [ + { + id: "msg_assistant_new", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + time: { created: 3 }, + }, + { + id: "msg_assistant_old", + type: "assistant", + metadata: { source: "snapshot" }, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + time: { created: 1 }, + }, + ], + }), + ) + await hydration + emitTwice(events, { + id: "evt_step_late_duplicate", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_old", + timestamp: 1, + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + + expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([ + "msg_assistant_new", + "msg_assistant_old", + "msg_assistant_older", + ]) + expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({ + metadata: { source: "snapshot" }, + content: [{ type: "text", id: "text-1", text: "" }], + }) + expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({ + content: [{ type: "text", id: "text-older", text: "" }], + }) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 69cec7bdcc..951fc7204b 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -7,6 +7,7 @@ import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { testEffect } from "../lib/effect" +import { Config } from "../../src/config/config" // kilocode_change const skills: Skill.Info[] = [ { @@ -44,6 +45,7 @@ const build: Agent.Info = { const it = testEffect( SystemPrompt.layer.pipe( Layer.provide(LocationServiceMap.layer), + Layer.provide(Config.defaultLayer), // kilocode_change Layer.provide( Layer.succeed( Skill.Service, diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 35c24e1b05..51396465fa 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -1,5 +1,4 @@ import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { Layer } from "effect" import { layer as locationLayer } from "./groups/location" @@ -43,5 +42,5 @@ export const handlers = Layer.mergeAll( Layer.provide(SessionV2.defaultLayer), Layer.provide(SessionExecutionLocal.defaultLayer), Layer.provide(PermissionSaved.defaultLayer), - Layer.provide(LocationServiceMap.layer), + // kilocode_change - the host provides LocationServiceMap so Kilo can install effective-reference initialization ) diff --git a/packages/server/src/kilocode/reference-reconciler.ts b/packages/server/src/kilocode/reference-reconciler.ts index f68a743b8e..cb1ea03917 100644 --- a/packages/server/src/kilocode/reference-reconciler.ts +++ b/packages/server/src/kilocode/reference-reconciler.ts @@ -1,13 +1,14 @@ import { Location } from "@opencode-ai/core/location" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Reference } from "@opencode-ai/core/reference" -import { Context, Effect } from "effect" +import { Context, Effect, Layer } from "effect" -export const ReferenceReconciler = Context.Reference< +export class ReferenceReconciler extends Context.Service< + ReferenceReconciler, Effect.Effect ->("@kilocode/ReferenceReconciler", { - defaultValue: () => Effect.void, -}) +>()("@kilocode/ReferenceReconciler") {} + +export const noop = Layer.succeed(ReferenceReconciler, Effect.void) export function reconcile(effect: Effect.Effect) { return Effect.flatMap(ReferenceReconciler, (reconciler) => Effect.andThen(reconciler, effect)) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 9741dd2339..48c95094dc 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -9,10 +9,12 @@ import { ServerAuth } from "./auth" import { handlers } from "./handlers" import { authorizationLayer } from "./middleware/authorization" import { schemaErrorLayer } from "./middleware/schema-error" +import { noop as referenceNoop } from "./kilocode/reference-reconciler" // kilocode_change export function createRoutes(password?: string) { return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers), + Layer.provide(referenceNoop), // kilocode_change - standalone server has no Kilo config reconciler Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), Layer.provide( diff --git a/packages/tui/src/component/kilo-logo.tsx b/packages/tui/src/component/kilo-logo.tsx index 486a514856..80f75a039b 100644 --- a/packages/tui/src/component/kilo-logo.tsx +++ b/packages/tui/src/component/kilo-logo.tsx @@ -1,4 +1,4 @@ -// kilocode_change new file +// kilocode_change - new file import { RGBA } from "@opentui/core" import { For, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme"