mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix(opencode): address fourth-pass upstream review
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -131,6 +131,22 @@ export function ensure(cache: RepositoryCache.Interface, item: Extract<Resolved,
|
||||
)
|
||||
}
|
||||
|
||||
function same(left: Reference.Source | undefined, right: Reference.Source) {
|
||||
if (!left || left.type !== right.type) return false
|
||||
if (left.type === "local" && right.type === "local") {
|
||||
return left.path === right.path && left.description === right.description && left.hidden === right.hidden
|
||||
}
|
||||
if (left.type === "git" && right.type === "git") {
|
||||
return (
|
||||
left.repository === right.repository &&
|
||||
left.branch === right.branch &&
|
||||
left.description === right.description &&
|
||||
left.hidden === right.hidden
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Keep Core V2 tools on the same effective Kilo config used by stable tools. Core's standalone
|
||||
// scanner cannot see account/managed config or KILO_CONFIG_CONTENT, so replace its provisional
|
||||
// references after both config systems finish booting.
|
||||
@@ -141,7 +157,6 @@ export const sync = Effect.fn("KiloReference.sync")(function* (input: {
|
||||
}) {
|
||||
const service = yield* Reference.Service
|
||||
const entries = resolveAll(input)
|
||||
if (entries.length === 0 && (yield* service.list()).length === 0) return
|
||||
const sources = entries.flatMap<readonly [string, Reference.Source]>((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)
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]),
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof createEventSource>, payload: Event) {
|
||||
const event = global(payload)
|
||||
events.emit(event)
|
||||
events.emit(event)
|
||||
}
|
||||
|
||||
test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
synced(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_model_1",
|
||||
type: "session.next.model.switched",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_model_1",
|
||||
timestamp: 0,
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_step_started_1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_input_1",
|
||||
type: "session.next.tool.input.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
timestamp: 2,
|
||||
callID: "call-1",
|
||||
name: "bash",
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_called_1",
|
||||
type: "session.next.tool.called",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
input: {},
|
||||
provider: { executed: false, metadata: { fake: { call: true } } },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_failed_1",
|
||||
type: "session.next.tool.failed",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 3,
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "aborted" },
|
||||
provider: { executed: false, metadata: { fake: { result: true } } },
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => {
|
||||
const assistant = sync.session.message.fromSession("session-1")[0]
|
||||
return (
|
||||
assistant?.type === "assistant" &&
|
||||
assistant.content[0]?.type === "tool" &&
|
||||
assistant.content[0].state.status === "error"
|
||||
)
|
||||
})
|
||||
|
||||
const assistant = sync.session.message.fromSession("session-1")[0]
|
||||
expect(assistant?.type).toBe("assistant")
|
||||
if (assistant?.type !== "assistant") return
|
||||
expect(assistant.id).toBe("msg_explicit_assistant_9")
|
||||
const tool = assistant.content[0]
|
||||
expect(tool?.type).toBe("tool")
|
||||
if (tool?.type !== "tool") return
|
||||
expect(tool.state.status).toBe("error")
|
||||
if (tool.state.status !== "error") return
|
||||
expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
|
||||
expect(tool.state.input).toEqual({})
|
||||
expect(tool.state.structured).toEqual({})
|
||||
expect(tool.state.content).toEqual([])
|
||||
expect(tool.provider).toEqual({
|
||||
executed: false,
|
||||
metadata: { fake: { call: true } },
|
||||
resultMetadata: { fake: { result: true } },
|
||||
})
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
|
||||
"assistant",
|
||||
"model-switched",
|
||||
"agent-switched",
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 renders admitted prompts only after promotion", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
synced(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_admitted_1",
|
||||
type: "session.next.prompt.admitted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 0,
|
||||
prompt: { text: "hello" },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
expect(sync.session.message.fromSession("session-1")).toEqual([])
|
||||
|
||||
emitTwice(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 1,
|
||||
prompt: { text: "hello" },
|
||||
timeCreated: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
||||
const message = sync.session.message.fromSession("session-1")[0]
|
||||
expect(message?.type).toBe("user")
|
||||
if (message?.type !== "user") return
|
||||
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 renders a promoted prompt when admission was missed", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
synced(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 1,
|
||||
prompt: { text: "hello" },
|
||||
timeCreated: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
||||
expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 projects live context updates with their message ID", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
synced(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_context_1",
|
||||
type: "session.next.context.updated",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_context_1",
|
||||
timestamp: 1,
|
||||
text: "Updated context",
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
||||
expect(sync.session.message.fromSession("session-1")[0]).toMatchObject({
|
||||
id: "msg_context_1",
|
||||
type: "system",
|
||||
text: "Updated context",
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
synced(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||
})
|
||||
response.resolve(json({ data: [] }))
|
||||
await hydration
|
||||
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
|
||||
["msg_agent_1", "agent-switched"],
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
synced(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 1,
|
||||
prompt: { text: "stale" },
|
||||
timeCreated: 0,
|
||||
},
|
||||
})
|
||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1")
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" },
|
||||
})
|
||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1")
|
||||
response.resolve(
|
||||
json({
|
||||
data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }],
|
||||
}),
|
||||
)
|
||||
await hydration
|
||||
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
|
||||
["msg_agent_1", "agent-switched"],
|
||||
["msg_user_1", "user"],
|
||||
])
|
||||
expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
synced(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_step_older",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_older",
|
||||
timestamp: 0,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_step_1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_old",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old")
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_text_1",
|
||||
type: "session.next.text.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_old",
|
||||
timestamp: 2,
|
||||
textID: "text-1",
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_text_older",
|
||||
type: "session.next.text.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_older",
|
||||
timestamp: 2,
|
||||
textID: "text-older",
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const messages = sync.session.message.fromSession("session-1")
|
||||
return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text")
|
||||
})
|
||||
response.resolve(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant_new",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_assistant_old",
|
||||
type: "assistant",
|
||||
metadata: { source: "snapshot" },
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
await hydration
|
||||
emitTwice(events, {
|
||||
id: "evt_step_late_duplicate",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_old",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([
|
||||
"msg_assistant_new",
|
||||
"msg_assistant_old",
|
||||
"msg_assistant_older",
|
||||
])
|
||||
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({
|
||||
metadata: { source: "snapshot" },
|
||||
content: [{ type: "text", id: "text-1", text: "" }],
|
||||
})
|
||||
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({
|
||||
content: [{ type: "text", id: "text-older", text: "" }],
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<void, never, Location.Service | PluginBoot.Service | Reference.Service>
|
||||
>("@kilocode/ReferenceReconciler", {
|
||||
defaultValue: () => Effect.void,
|
||||
})
|
||||
>()("@kilocode/ReferenceReconciler") {}
|
||||
|
||||
export const noop = Layer.succeed(ReferenceReconciler, Effect.void)
|
||||
|
||||
export function reconcile<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return Effect.flatMap(ReferenceReconciler, (reconciler) => Effect.andThen(reconciler, effect))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user