fix: harden OpenCode v1.14.48 integration

This commit is contained in:
marius-kilocode
2026-06-08 14:38:13 +02:00
parent 5706abce76
commit cc03ffc581
25 changed files with 658 additions and 66 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Preserve image attachments when Photon is unavailable, enforce attachment limits for user images, and correlate shell lifecycle events correctly.
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.13",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:storybook": "bun --cwd packages/storybook storybook",
@@ -1,5 +1,6 @@
package ai.kilocode.backend.cli
import ai.kilocode.backend.migration.session.LegacySessionIds
import ai.kilocode.jetbrains.api.infrastructure.Serializer
import ai.kilocode.jetbrains.api.model.Session
import ai.kilocode.jetbrains.api.model.SessionStatus
@@ -40,6 +41,27 @@ class SessionModelSerializationTest {
assertNull(obj.summary)
}
@Test
fun `Session decodes canonical migrated and unprefixed legacy IDs`() {
val src = """{
"id": "ses_abc",
"slug": "canonical",
"projectID": "prj_123",
"directory": "/test/project",
"title": "Canonical",
"version": "1.0.0",
"time": {"created": 1000, "updated": 2000}
}"""
val migrated = LegacySessionIds.createSessionId("task-abc")
val canonical = json.decodeFromString<Session>(src)
val imported = json.decodeFromString<Session>(src.replace("ses_abc", migrated))
val legacy = json.decodeFromString<Session>(src.replace("ses_abc", "s1"))
assertEquals("ses_abc", canonical.id)
assertEquals(migrated, imported.id)
assertEquals("s1", legacy.id)
}
@Test
fun `Session with summary`() {
val src = """{
+1 -1
View File
@@ -345,7 +345,7 @@ export const layer = Layer.effect(
`Repository: ${reference.repository}`,
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
`Cached directory: ${reference.path}`,
`OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`, // kilocode_change
`Kilo materializes this configured repository before use. Do not call repo_clone for this reference.`, // kilocode_change
`Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
`Return exact absolute file paths for findings whenever possible.`,
].join("\n\n")
+3 -15
View File
@@ -70,6 +70,7 @@ import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { kitty, resetTerminalState } from "@/kilocode/cli/cmd/tui/util/terminal" // kilocode_change
import * as AppExit from "@/kilocode/tui/app-exit" // kilocode_change
import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
@@ -663,14 +664,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
category: "System",
},
{
name: "app.exit",
title: "Exit the app",
slashName: "exit",
slashAliases: ["quit", "q"],
run: () => exit(),
category: "System",
},
AppExit.command(exit), // kilocode_change
{
name: "app.debug",
title: "Toggle debug panel",
@@ -812,13 +806,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}))
useBindings(() => ({
enabled: () => {
const ok = command.matcher.get()
if (!ok) return false
const current = promptRef.current
if (!current?.focused) return true
return current.current.input === ""
},
enabled: () => AppExit.enabled(command.matcher.get(), promptRef.current), // kilocode_change
bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
}))
@@ -1534,12 +1534,10 @@ export function Prompt(props: PromptProps) {
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}}
/* kilocode_change start */
onCursorChange={() => {
/* kilocode_change */ onCursorChange={() => {
setCursorVersion((value) => value + 1)
if (store.mode === "normal") auto()?.onCursorChange()
}}
/* kilocode_change end */
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
e.preventDefault()
@@ -1641,9 +1639,7 @@ export function Prompt(props: PromptProps) {
</box>
<box
height={1}
/* kilocode_change start */
flexShrink={0}
/* kilocode_change end */
/* kilocode_change */ flexShrink={0}
border={["left"]}
borderColor={borderHighlight()}
customBorderChars={{
@@ -164,15 +164,13 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
body={
<Switch>
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
{/* kilocode_change start */}
{/* kilocode_change */}
<TextBody title={"This will allow " + props.request.permission + " permanently."} />
{/* kilocode_change end */}
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
{/* kilocode_change start */}
{/* kilocode_change */}
<text fg={theme.textMuted}>This will allow the following patterns permanently</text>
{/* kilocode_change end */}
<box>
<For each={props.request.always}>
{(pattern) => (
@@ -453,9 +451,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
title="Permission required"
header={header()}
body={current.body}
/* kilocode_change start */
options={options}
/* kilocode_change end */
/* kilocode_change */ options={options}
escapeKey="reject"
fullscreen
onSelect={(option) => {
+1 -1
View File
@@ -175,7 +175,7 @@ export const Info = Schema.Struct({
description: "Server configuration for the kilo serve command", // kilocode_change
}),
command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({
description: "Command configuration, see https://opencode.ai/docs/commands", // kilocode_change
description: "Command configuration, see https://opencode.ai/docs/commands",
}),
skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),
reference: Schema.optional(ConfigReference.Info).annotate({
+103 -5
View File
@@ -3,13 +3,99 @@ import type { MessageV2 } from "@/session/message-v2"
import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer, Schema } from "effect"
const MAX_BASE64_BYTES = 4.5 * 1024 * 1024
export const MAX_BASE64_BYTES = 4.5 * 1024 * 1024 // kilocode_change - share user file pre-read limit
const MAX_WIDTH = 2000
const MAX_HEIGHT = 2000
const AUTO_RESIZE = true
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
const log = Log.create({ service: "image" })
// kilocode_change start - preserve valid in-limit images when Photon is unavailable
function dimensions(mime: string, data: Buffer) {
if (
mime === "image/png" &&
data.length >= 24 &&
data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) &&
data.subarray(12, 16).toString("ascii") === "IHDR"
)
return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) }
if (mime === "image/gif" && data.length >= 10) {
const head = data.subarray(0, 6).toString("ascii")
if (head === "GIF87a" || head === "GIF89a")
return { width: data.readUInt16LE(6), height: data.readUInt16LE(8) }
}
if ((mime === "image/jpeg" || mime === "image/jpg") && data.length >= 4 && data.readUInt16BE(0) === 0xffd8) {
for (let offset = 2; offset + 8 < data.length; ) {
if (data[offset] !== 0xff) {
offset++
continue
}
const marker = data[offset + 1]
if (marker === 0xd9 || marker === 0xda) break
const length = data.readUInt16BE(offset + 2)
if (length < 2 || offset + length + 2 > data.length) break
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker))
return { width: data.readUInt16BE(offset + 7), height: data.readUInt16BE(offset + 5) }
offset += length + 2
}
}
if (
mime === "image/webp" &&
data.length >= 30 &&
data.subarray(0, 4).toString("ascii") === "RIFF" &&
data.subarray(8, 12).toString("ascii") === "WEBP"
) {
const chunk = data.subarray(12, 16).toString("ascii")
if (chunk === "VP8X")
return {
width: 1 + data.readUIntLE(24, 3),
height: 1 + data.readUIntLE(27, 3),
}
if (chunk === "VP8L" && data[20] === 0x2f)
return {
width: 1 + data[21] + ((data[22] & 0x3f) << 8),
height: 1 + (data[22] >> 6) + (data[23] << 2) + ((data[24] & 0x0f) << 10),
}
if (chunk === "VP8 " && data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a)
return { width: data.readUInt16LE(26) & 0x3fff, height: data.readUInt16LE(28) & 0x3fff }
}
}
export function fallback(
input: MessageV2.FilePart,
base64: string,
max: { bytes: number; width: number; height: number },
) {
const bytes = Buffer.byteLength(base64, "utf8")
if (bytes > max.bytes)
return new SizeError({
bytes,
max: max.bytes,
width: 0,
height: 0,
max_width: max.width,
max_height: max.height,
})
const data = Buffer.from(base64, "base64")
const canonical = data.toString("base64").replace(/=+$/, "") === base64.replace(/=+$/, "")
const size = canonical ? dimensions(input.mime, data) : undefined
if (!base64 || !size) return new DecodeError()
if (size.width > max.width || size.height > max.height)
return new SizeError({
bytes,
max: max.bytes,
width: size.width,
height: size.height,
max_width: max.width,
max_height: max.height,
})
return input
}
// kilocode_change end
export class PhotonUnavailableError extends Schema.TaggedErrorClass<PhotonUnavailableError>()(
"ImagePhotonUnavailableError",
{},
@@ -63,11 +149,13 @@ export const layer = Layer.effect(
try {
const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } }))
.default
// Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path.
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
// kilocode_change start - use Kilo's embedded WASM path in compiled binaries
;(globalThis as typeof globalThis & { __KILOCODE_PHOTON_WASM_PATH?: string }).__KILOCODE_PHOTON_WASM_PATH =
photonWasm
// kilocode_change end
return await import("@silvia-odwyer/photon-node")
} catch {
} catch (err) {
log.error("failed to load Photon image processor", { err }) // kilocode_change
return null
}
}),
@@ -86,7 +174,17 @@ export const layer = Layer.effect(
const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length)
const photon = yield* loadPhoton
if (!photon) return yield* new PhotonUnavailableError()
// kilocode_change start - fail closed on invalid bytes but preserve valid in-limit images without Photon
if (!photon) {
const result = fallback(input, base64, {
bytes: info.maxBase64Bytes,
width: info.maxWidth,
height: info.maxHeight,
})
if (result instanceof Error) return yield* result
return result
}
// kilocode_change end
const decoded = yield* Effect.sync(() => {
try {
@@ -0,0 +1,21 @@
export type Prompt = {
readonly focused: boolean
readonly current: { readonly input: string }
}
export function enabled(matcher: boolean, prompt?: Prompt) {
if (!matcher) return false
if (!prompt?.focused) return true
return prompt.current.input === ""
}
export function command(exit: () => void) {
return {
name: "app.exit",
title: "Exit the app",
slashName: "exit",
slashAliases: ["quit", "q"],
run: exit,
category: "System",
}
}
@@ -1,4 +1,5 @@
import * as InstanceState from "@/effect/instance-state"
import { Image } from "@/image/image" // kilocode_change - classify user image validation defects
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { KiloSessionHttpApi } from "@/kilocode/server/httpapi/session-fork" // kilocode_change
import { Agent } from "@/agent/agent"
@@ -269,7 +270,18 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
.pipe(
Effect.provideService(InstanceRef, instance),
Effect.provideService(WorkspaceRef, workspace),
Effect.mapError(() => new HttpApiError.BadRequest({})),
// kilocode_change start - reject only typed user image validation defects as request errors
Effect.catchCause((cause) => {
const error = Cause.squash(cause)
if (
error instanceof Image.InvalidDataUrlError ||
error instanceof Image.DecodeError ||
error instanceof Image.SizeError
)
return Effect.fail(new HttpApiError.BadRequest({}))
return Effect.failCause(cause)
}),
// kilocode_change end
)
return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {
contentType: "application/json",
+3 -1
View File
@@ -638,6 +638,7 @@ export const layer: Layer.Layer<
include: selected.tail_start_id,
})
}
// kilocode_change start - export self-contained compaction capture
const parent = KiloSession.resolveParent(input.sessionID)
const found = KiloSession.resolveRoot(input.sessionID)
const root = parent ? (found === input.sessionID ? parent : found) : input.sessionID
@@ -666,11 +667,12 @@ export const layer: Layer.Layer<
outputTokens: processor.message.tokens.output,
},
})
// kilocode_change end
yield* prune({ sessionID: input.sessionID, reason: "post-compaction" })
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
}
return fallback
// kilocode_change end
return fallback // kilocode_change
})
const create = Effect.fn("SessionCompaction.create")(function* (input: {
+51 -14
View File
@@ -69,6 +69,7 @@ import * as DateTime from "effect/DateTime"
import { eq } from "@/storage/db"
import * as Database from "@/storage/db"
import { SessionTable } from "./session.sql"
import { Image } from "@/image/image" // kilocode_change - normalize user image data before persistence
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -132,6 +133,7 @@ export const layer = Layer.effect(
const summary = yield* SessionSummary.Service
const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service
const image = yield* Image.Service // kilocode_change - normalize user image data before persistence
const sync = yield* SyncEvent.Service // kilocode_change - preserve Kilo v2 event dual-write wiring
const runner = Effect.fn("SessionPrompt.runner")(function* () {
return yield* EffectBridge.make()
@@ -842,7 +844,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
providerID: model.providerID,
}
yield* sessions.updateMessage(msg)
const callID = ulid()
const callID = ulid() // kilocode_change - correlate v2 shell events with the persisted tool part
const started = Date.now()
const part: MessageV2.ToolPart = {
type: "tool",
@@ -850,7 +852,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
messageID: msg.id,
sessionID: input.sessionID,
tool: ShellID.ToolID,
callID: ulid(),
callID, // kilocode_change
state: {
status: "running",
time: { start: started },
@@ -1143,6 +1145,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ ...part, messageID: info.id, sessionID: input.sessionID },
]
}
// kilocode_change start - normalize user image data before persistence
if (part.mime.startsWith("image/")) {
const file: MessageV2.FilePart = {
...part,
id: part.id ? PartID.make(part.id) : PartID.ascending(),
messageID: info.id,
sessionID: input.sessionID,
}
return [yield* image.normalize(file).pipe(Effect.orDie)]
}
// kilocode_change end
break
case "file:": {
log.info("file", { mime: part.mime })
@@ -1285,6 +1298,39 @@ NOTE: At any point in time through this workflow you should feel free to ask the
]
}
// kilocode_change start - reject oversized user image files before reading and base64 allocation
if (mime.startsWith("image/")) {
const limit = (yield* config.get()).attachment?.image?.max_base64_bytes ?? Image.MAX_BASE64_BYTES
const stat = yield* fsys.stat(filepath).pipe(Effect.catch(Effect.die))
const encoded = ((stat.size + 2n) / 3n) * 4n
if (encoded > BigInt(limit))
return yield* Effect.die(
new Image.SizeError({
bytes: Number(encoded > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : encoded),
max: limit,
width: 0,
height: 0,
max_width: 0,
max_height: 0,
}),
)
}
// kilocode_change end
const file: MessageV2.FilePart = {
id: part.id ? PartID.make(part.id) : PartID.ascending(),
messageID: info.id,
sessionID: input.sessionID,
type: "file",
url:
`data:${mime};base64,` +
Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"),
mime,
filename: part.filename!,
source: part.source,
}
// kilocode_change start - apply image limits after resolving user file URLs
const attachment = mime.startsWith("image/") ? yield* image.normalize(file).pipe(Effect.orDie) : file
// kilocode_change end
return [
{
messageID: info.id,
@@ -1293,18 +1339,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
synthetic: true,
text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`,
},
{
id: part.id,
messageID: info.id,
sessionID: input.sessionID,
type: "file",
url:
`data:${mime};base64,` +
Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"),
mime,
filename: part.filename!,
source: part.source,
},
attachment,
]
}
}
@@ -2062,6 +2097,8 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(LSP.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Truncate.defaultLayer),
).pipe(
Layer.provide(Image.defaultLayer), // kilocode_change - provide user image normalization service
Layer.provide(Provider.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Instruction.defaultLayer),
@@ -0,0 +1,47 @@
// kilocode_change - new file
import { describe, expect, test } from "bun:test"
import { TuiConfig } from "../../../../src/cli/cmd/tui/config/tui"
import * as AppExit from "../../../../src/kilocode/tui/app-exit"
const prompt = (focused: boolean, input: string): AppExit.Prompt => ({
focused,
current: { input },
})
describe("app_exit", () => {
test("blocks every configured exit binding when the command matcher is disabled", () => {
const bindings = TuiConfig.resolve({}).keybinds.gather("app_exit", ["app.exit"])
expect(bindings.length).toBeGreaterThan(0)
for (const binding of bindings) expect(AppExit.enabled(false)).toBe(false)
})
test("permits exit without a prompt ref", () => {
expect(AppExit.enabled(true)).toBe(true)
})
test("blocks focused prompts with non-empty input including whitespace", () => {
expect(AppExit.enabled(true, prompt(true, "keep typing"))).toBe(false)
expect(AppExit.enabled(true, prompt(true, " "))).toBe(false)
})
test("permits focused empty and unfocused prompts", () => {
expect(AppExit.enabled(true, prompt(true, ""))).toBe(true)
expect(AppExit.enabled(true, prompt(false, "keep typing"))).toBe(true)
})
test("registers slash exit independently of binding enablement", () => {
let exited = false
const command = AppExit.command(() => {
exited = true
})
expect(command).toMatchObject({
name: "app.exit",
slashName: "exit",
slashAliases: ["quit", "q"],
})
command.run()
expect(exited).toBe(true)
})
})
+41 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import { Image } from "@/image/image"
import { MessageID, PartID, SessionID } from "@/session/schema"
@@ -57,6 +57,46 @@ describe("Image", () => {
}),
)
// kilocode_change start - cover Kilo's Photon-unavailable fallback
test("preserves a valid in-limit image without Photon", () => {
const data = "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA"
const input = part("image/webp", data)
expect(Image.fallback(input, data, { bytes: 1024, width: 2000, height: 2000 })).toEqual(input)
})
test("rejects non-image bytes without Photon", () => {
const data = Buffer.from("not an image").toString("base64")
const result = Image.fallback(part("image/png", data), data, { bytes: 1024, width: 2000, height: 2000 })
expect(result).toBeInstanceOf(Image.DecodeError)
})
test("rejects oversized encoded input before decoding without Photon", () => {
const data = "A".repeat(8 * 1024 * 1024)
const result = Image.fallback(part("image/png", data), data, { bytes: 1024, width: 2000, height: 2000 })
expect(result).toBeInstanceOf(Image.SizeError)
if (result instanceof Image.SizeError) expect(result.bytes).toBe(data.length)
})
test("rejects an image with oversized header dimensions without Photon", () => {
const png = Buffer.alloc(24)
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png)
png.write("IHDR", 12, "ascii")
png.writeUInt32BE(10_000, 16)
png.writeUInt32BE(1, 20)
const data = png.toString("base64")
const result = Image.fallback(part("image/png", data), data, { bytes: 1024, width: 2000, height: 2000 })
expect(result).toBeInstanceOf(Image.SizeError)
if (result instanceof Image.SizeError) {
expect(result.width).toBe(10_000)
expect(result.height).toBe(1)
}
})
// kilocode_change end
tiny.effect("fails with a typed size error when no resized candidate fits", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
@@ -168,6 +168,7 @@ function makeHttp() {
TestLLMServer.layer,
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(summary),
Layer.provideMerge(runState),
Layer.provideMerge(compact),
@@ -26,10 +26,9 @@ describe("session export worker e2e", () => {
worker.postMessage({ kind: "event", approxBytes: 1000, envelope: env })
}
await until(() => rows(db).length === 3)
const out = rows(db)
await shutdown(worker)
worker.terminate()
const out = rows(db)
expect(out.map((row) => row.type)).toEqual([
"llm_request_started",
"workspace_baseline_completed",
@@ -161,6 +161,7 @@ function makeHttp() {
TestLLMServer.layer,
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(summary),
Layer.provideMerge(run),
Layer.provideMerge(compact),
@@ -155,6 +155,7 @@ function makeHttp() {
TestLLMServer.layer,
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(summary),
Layer.provideMerge(run),
Layer.provideMerge(compact),
@@ -654,6 +654,75 @@ describe("HttpApi SDK", () => {
),
)
// kilocode_change start - verify invalid user images fail at the real SDK boundary
serverPathParity("rejects malformed user image data before persistence", (serverPath) =>
withStandardProject(serverPath, ({ sdk }) =>
Effect.gen(function* () {
const session = yield* capture(() => sdk.session.create({ title: "invalid image" }))
const sessionID = String(record(session.data).id)
const prompt = yield* capture(() =>
sdk.session.prompt({
sessionID,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "image/png",
filename: "not-an-image.png",
url: "data:image/png;base64,bm90LWltYWdl",
},
],
}),
)
const messages = yield* capture(() => sdk.session.messages({ sessionID }))
expect(prompt.status).toBe(400)
expect(JSON.stringify(messages.data)).not.toContain("not-an-image.png")
return {
promptStatus: prompt.status,
persisted: JSON.stringify(messages.data).includes("not-an-image.png"),
}
}),
),
)
serverPathParity("rejects oversized user image files before persistence", (serverPath) =>
withProject(
serverPath,
{ config: { attachment: { image: { max_base64_bytes: 4 } } } },
({ sdk, directory }) =>
Effect.gen(function* () {
const filepath = path.join(directory, "oversized.png")
yield* call(() => Bun.write(filepath, Buffer.alloc(1024, 1)))
const session = yield* capture(() => sdk.session.create({ title: "oversized image" }))
const sessionID = String(record(session.data).id)
const prompt = yield* capture(() =>
sdk.session.prompt({
sessionID,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "image/png",
filename: "oversized.png",
url: `file://${filepath}`,
},
],
}),
)
const messages = yield* capture(() => sdk.session.messages({ sessionID }))
expect(prompt.status).toBe(400)
expect(JSON.stringify(messages.data)).not.toContain("oversized.png")
return { promptStatus: prompt.status, persisted: JSON.stringify(messages.data).includes("oversized.png") }
}),
),
)
// kilocode_change end
serverPathParity("matches generated SDK prompt streaming through fake LLM", (serverPath) =>
withFakeLlm(serverPath, ({ sdk, llm }) =>
Effect.gen(function* () {
@@ -688,6 +757,62 @@ describe("HttpApi SDK", () => {
),
)
// kilocode_change start - verify provider errors remain in successful assistant messages
serverPathParity("preserves provider errors through the generated SDK", (serverPath) =>
withFakeLlm(serverPath, ({ sdk, llm }) =>
Effect.gen(function* () {
const gateway = { error: { code: "PAID_MODEL_AUTH_REQUIRED", message: "Authentication required" } }
const create = () =>
capture(() =>
sdk.session.create({
title: "provider error",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
const prompt = (sessionID: string) => ({
sessionID,
agent: "build",
model: { providerID: "test", modelID: "test-model" },
parts: [{ type: "text" as const, text: "trigger provider error" }],
})
yield* llm.error(401, gateway)
const tupleSession = yield* create()
const tuple = yield* capture(() => sdk.session.prompt(prompt(String(record(tupleSession.data).id))))
yield* llm.error(401, gateway)
const strictSession = yield* create()
const strict = yield* call(() =>
sdk.session.prompt(prompt(String(record(strictSession.data).id)), { throwOnError: true }),
)
const tupleError = record(record(tuple.data).info).error
const tupleData = record(record(tupleError).data)
const strictError = record(record(record(strict).data).info).error
const strictData = record(record(strictError).data)
expect(tuple.status).toBe(200)
expect(record(tupleError).name).toBe("APIError")
expect(tupleData.statusCode).toBe(401)
expect(JSON.parse(String(tupleData.responseBody))).toEqual(gateway)
expect(record(strictError).name).toBe("APIError")
expect(strictData.statusCode).toBe(401)
expect(JSON.parse(String(strictData.responseBody))).toEqual(gateway)
return {
tupleStatus: tuple.status,
tupleName: record(tupleError).name,
providerStatus: tupleData.statusCode,
providerBody: tupleData.responseBody,
strictName: record(strictError).name,
strictStatus: strictData.statusCode,
strictBody: strictData.responseBody,
}
}),
),
)
// kilocode_change end
httpapi(
"includes project skills in REST API async prompt context",
withFakeLlmProject("default", { setup: writeProjectSkill }, ({ sdk, llm }) =>
+4 -4
View File
@@ -412,7 +412,7 @@ describe("session.llm.stream", () => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json", // kilocode_change
$schema: "https://opencode.ai/config.json",
enabled_providers: [providerID],
provider: {
[providerID]: {
@@ -502,7 +502,7 @@ describe("session.llm.stream", () => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json", // kilocode_change
$schema: "https://opencode.ai/config.json",
enabled_providers: [providerID],
provider: {
[providerID]: {
@@ -725,7 +725,7 @@ describe("session.llm.stream", () => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json", // kilocode_change
$schema: "https://opencode.ai/config.json",
enabled_providers: ["openai"],
provider: {
openai: {
@@ -962,7 +962,7 @@ describe("session.llm.stream", () => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json", // kilocode_change
$schema: "https://opencode.ai/config.json",
enabled_providers: ["anthropic"],
provider: {
anthropic: {
+142 -3
View File
@@ -6,7 +6,7 @@ import { Telemetry } from "@kilocode/kilo-telemetry"
// kilocode_change end
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { fileURLToPath, pathToFileURL } from "url"
import { NamedError } from "@opencode-ai/core/util/error"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
@@ -454,6 +454,113 @@ it.live("new prompt dismisses a pending question", () =>
)
// kilocode_change end
// kilocode_change start - cover user image normalization before persistence
it.live("normalizes user data images before persistence", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "User image" })
const url = "data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA"
const result = yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "file", mime: "image/webp", filename: "pixel.webp", url }],
})
expect(result.parts).toEqual(
expect.arrayContaining([expect.objectContaining({ type: "file", mime: "image/webp", url })]),
)
const saved = yield* sessions.messages({ sessionID: chat.id })
expect(saved.flatMap((message) => message.parts)).toEqual(
expect.arrayContaining([expect.objectContaining({ type: "file", mime: "image/webp", url })]),
)
}),
{ git: true, config: providerCfg },
),
)
it.live("rejects malformed user data images before persistence", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Invalid user image" })
const exit = yield* prompt
.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "image/png",
filename: "invalid.png",
url: `data:image/png;base64,${Buffer.from("not an image").toString("base64")}`,
},
],
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
const saved = yield* sessions.messages({ sessionID: chat.id })
expect(saved.flatMap((message) => message.parts).some((part) => part.type === "file")).toBe(false)
}),
{ git: true, config: providerCfg },
),
)
it.live("normalizes user image file URLs after reading them", () =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ dir }) {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "User image file" })
const data = "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA"
const filepath = path.join(dir, "pixel.webp")
yield* Effect.promise(() => Bun.write(filepath, Buffer.from(data, "base64")))
const result = yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "file", mime: "image/webp", filename: "pixel.webp", url: pathToFileURL(filepath).href }],
})
expect(result.parts).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "file", mime: "image/webp", url: `data:image/webp;base64,${data}` }),
]),
)
}),
{ git: true, config: providerCfg },
),
)
it.live("leaves non-image data parts untouched", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "User data" })
const url = "data:application/octet-stream;base64,bm90IGFuIGltYWdl"
const result = yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "file", mime: "application/octet-stream", filename: "data.bin", url }],
})
expect(result.parts).toEqual(expect.arrayContaining([expect.objectContaining({ type: "file", url })]))
}),
{ git: true, config: providerCfg },
),
)
// kilocode_change end
it.live("prompt emits v2 prompted and synthetic events", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
@@ -1423,6 +1530,38 @@ unix("shell commands can change directory after startup", () =>
),
)
// kilocode_change start - verify shell v2 events correlate with the persisted tool part
unix("shell correlates the persisted tool part with its completed v2 record", () =>
provideTmpdirInstance(
(_dir) =>
Effect.gen(function* () {
const { prompt, chat } = yield* boot()
const result = yield* prompt.shell({
sessionID: chat.id,
agent: "build",
command: "printf correlated",
})
const tool = completedTool(result.parts)
if (!tool) return
const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
Effect.provide(SessionV2.layer),
)
const shell = messages.find((message) => message.type === "shell")
expect(shell).toMatchObject({
type: "shell",
callID: tool.callID,
command: "printf correlated",
output: "correlated",
time: { completed: expect.anything() },
})
}),
{ git: true, config: cfg },
),
)
// kilocode_change end
unix("shell lists files from the project directory", () =>
provideTmpdirInstance(
(dir) =>
@@ -1912,7 +2051,7 @@ it.live(
),
]),
)
}),
}).pipe(Effect.scoped), // kilocode_change - scope test finalizers explicitly
{ git: true, config: cfg },
),
30_000,
@@ -1955,7 +2094,7 @@ it.live(
),
]),
)
}),
}).pipe(Effect.scoped), // kilocode_change - scope test finalizers explicitly
{ git: true, config: cfg },
),
30_000,
@@ -0,0 +1,62 @@
// kilocode_change - new file
import { describe, expect, test } from "bun:test"
import * as DateTime from "effect/DateTime"
import { SessionID } from "../../src/session/schema"
import { EventV2 } from "../../src/v2/event"
import { SessionEvent } from "../../src/v2/session-event"
import { SessionMessageUpdater } from "../../src/v2/session-message-updater"
describe("v2 shell event correlation", () => {
test("an unmatched end is ignored before a matching start and end complete one record", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session")
const callID = "call"
const updater = SessionMessageUpdater.memory(state)
SessionMessageUpdater.update(updater, {
id: EventV2.ID.create(),
type: "session.next.shell.ended",
data: {
sessionID,
timestamp: DateTime.makeUnsafe(0),
callID: "missing",
output: "ignored",
},
} satisfies SessionEvent.Event)
expect(state.messages).toEqual([])
SessionMessageUpdater.update(updater, {
id: EventV2.ID.create(),
type: "session.next.shell.started",
data: {
sessionID,
timestamp: DateTime.makeUnsafe(1),
callID,
command: "pwd",
},
} satisfies SessionEvent.Event)
SessionMessageUpdater.update(updater, {
id: EventV2.ID.create(),
type: "session.next.shell.ended",
data: {
sessionID,
timestamp: DateTime.makeUnsafe(2),
callID,
output: "/tmp",
},
} satisfies SessionEvent.Event)
expect(state.messages).toHaveLength(1)
expect(state.messages[0]).toMatchObject({
type: "shell",
callID,
command: "pwd",
output: "/tmp",
time: {
created: DateTime.makeUnsafe(1),
completed: DateTime.makeUnsafe(2),
},
})
})
})
+2 -2
View File
@@ -30,11 +30,11 @@ const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
const unix = process.platform !== "win32" ? it.live : it.live.skip // kilocode_change
// kilocode_change - skip on windows: address windows ci failures #9496
const unix = process.platform !== "win32" ? it.live : it.live.skip
describe("tool.skill", () => {
unix("execute returns skill content block with files", () =>
// kilocode_change
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
@@ -7,8 +7,8 @@ index 8f4144d..b83e9a9 100644
};
-const path = require('path').join(__dirname, 'photon_rs_bg.wasm');
+// Allow opencode's Bun compiled binary to point photon-node at its embedded wasm asset.
+const path = globalThis.__OPENCODE_PHOTON_WASM_PATH || require('path').join(__dirname, 'photon_rs_bg.wasm');
+// Allow Kilo's Bun compiled binary to point photon-node at its embedded wasm asset.
+const path = globalThis.__KILOCODE_PHOTON_WASM_PATH || require('path').join(__dirname, 'photon_rs_bg.wasm');
const bytes = require('fs').readFileSync(path);
const wasmModule = new WebAssembly.Module(bytes);