diff --git a/.changeset/calm-image-events.md b/.changeset/calm-image-events.md new file mode 100644 index 000000000000..105f722b6cfc --- /dev/null +++ b/.changeset/calm-image-events.md @@ -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. diff --git a/.opencode-version b/.opencode-version index dfbc2e653739..6ce43629e9bc 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.14.46 +v1.14.48 diff --git a/bun.lock b/bun.lock index ed7f7b9dcd57..75f50f18efc4 100644 --- a/bun.lock +++ b/bun.lock @@ -433,6 +433,7 @@ "@pierre/diffs": "catalog:", "@secretlint/core": "10.2.2", "@secretlint/secretlint-rule-preset-recommend": "10.2.2", + "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", @@ -680,9 +681,10 @@ "tree-sitter-bash", ], "patchedDependencies": { + "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", - "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.46", @@ -1852,6 +1854,8 @@ "@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="], + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@simple-git/args-pathspec": ["@simple-git/args-pathspec@1.0.3", "", {}, "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA=="], "@simple-git/argv-parser": ["@simple-git/argv-parser@1.1.1", "", { "dependencies": { "@simple-git/args-pathspec": "^1.0.3" } }, "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw=="], diff --git a/package.json b/package.json index a0e05947af14..1a2d635250bd 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ }, "patchedDependencies": { "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md new file mode 100644 index 000000000000..f6aaed4358d1 --- /dev/null +++ b/packages/http-recorder/README.md @@ -0,0 +1,214 @@ +# @opencode-ai/http-recorder + +Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests +exercise real request shapes against deterministic, version-controlled +cassettes — no manual mocks, no flakes from upstream drift. + +## Install + +Internal package; depended on as `@opencode-ai/http-recorder` from another +workspace package. + +```ts +import { HttpRecorder } from "@opencode-ai/http-recorder" +``` + +## Quickstart + +Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`. +By default the layer records on first run and replays on subsequent runs — +no env-var ternary at the call site, and `CI=true` forces strict replay so +missing cassettes fail loudly in CI rather than silently re-recording. + +```ts +import { Effect } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { HttpRecorder } from "@opencode-ai/http-recorder" + +const program = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1")) + return yield* response.json +}) + +// Records if the cassette is missing, replays if it exists. +// In CI (CI=true) always replays — fails loudly on missing fixtures. +Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one")))) + +// Force a refresh — always hits upstream and overwrites. +Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" })))) +``` + +## Modes + +| Mode | Behavior | +| ------------- | ----------------------------------------------------------------------------------- | +| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. | +| `replay` | Strict — match the request to a recorded interaction; error if none. | +| `record` | Execute upstream, append the interaction, write the cassette. | +| `passthrough` | Bypass the recorder entirely — just call upstream. | + +## Cassette format + +A cassette is JSON at `test/fixtures/recordings/.json`: + +```json +{ + "version": 1, + "metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." }, + "interactions": [ + { + "transport": "http", + "request": { "method": "GET", "url": "...", "headers": {...}, "body": "" }, + "response": { "status": 200, "headers": {...}, "body": "..." } + } + ] +} +``` + +Cassettes are normal source files — review them, diff them, commit them. + +## Request matching + +By default, requests match on canonicalized method, URL, headers, and JSON +body (object keys sorted). Two dispatch strategies are available: + +- **`match`** (default) — find the first recorded interaction whose request + matches the incoming request. Same request twice returns the same response. +- **`sequential`** — return interactions in the order they were recorded, + validating each one matches as the cursor advances. Use for ordered flows + where the same URL is hit multiple times with meaningful state changes + (pagination, retries, polling). + +```ts +HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" }) +``` + +Supply your own matcher via `match: (incoming, recorded) => boolean` for +custom equivalence (e.g. ignoring a timestamp field in the body). + +## Redaction & secret safety + +Cassettes get checked in, so the recorder is aggressive about not letting +secrets escape. Redaction is configured by composing a `Redactor`: + +```ts +import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder" + +HttpRecorder.cassetteLayer("anthropic/messages", { + redactor: Redactor.defaults({ + requestHeaders: { allow: ["content-type", "anthropic-version"] }, + url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") }, + body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }), + }), +}) +``` + +`Redactor.defaults({ … })` composes the four built-in redactors with your +overrides. For full control, build the stack yourself: + +```ts +const redactor = Redactor.compose( + Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }), + Redactor.responseHeaders(), + Redactor.url({ query: ["session-id"] }), + Redactor.body((parsed) => /* … */), +) +``` + +What each layer does: + +- **`requestHeaders` / `responseHeaders`** — strip headers to a small + allow-list (request default: `content-type`, `accept`, `openai-beta`; + response default: `content-type`). Sensitive headers within the + allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens, + …) are replaced with `[REDACTED]`. +- **`url`** — query parameters matching common secret names (`api_key`, + `token`, `signature`, AWS signing params, …) are replaced with + `[REDACTED]`. URL user/password are replaced. `transform` runs after + built-in redaction for path-level scrubbing. +- **`body`** — receives the parsed JSON request body and returns a redacted + version. No-op for non-JSON bodies. + +After assembling the cassette, the recorder scans every string for known +secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys, +AWS access keys, GitHub tokens, PEM blocks) and for values matching any +environment variable named like a credential. If anything is found, the +cassette is **not written** and the request fails with `UnsafeCassetteError` +listing what was detected. + +## WebSocket recording + +WebSocket support records the open frame plus client/server message +streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions +can live in the same cassette. + +```ts +import { HttpRecorder } from "@opencode-ai/http-recorder" +import { Effect } from "effect" + +const program = Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + const executor = yield* HttpRecorder.makeWebSocketExecutor({ + name: "ws/subscribe", + cassette, + live: liveExecutor, + }) + // use executor.open(...) +}) +``` + +## Inspecting cassettes programmatically + +`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read` +returns the recorded interactions for a name; the file format is hidden +behind the seam. Useful for CI checks: + +```ts +import { HttpRecorder } from "@opencode-ai/http-recorder" +import { Effect } from "effect" + +const audit = Effect.gen(function* () { + const cassettes = yield* HttpRecorder.Cassette.Service + const entries = yield* cassettes.list() + const issues = yield* Effect.forEach(entries, (entry) => + cassettes + .read(entry.name) + .pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))), + ) + return issues.filter((i) => i.findings.length > 0) +}) +``` + +`cassetteLayer` is the batteries-included entry point — it provides +`Cassette.fileSystem({ directory })` automatically. If you want to provide +your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's +own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` / +`Cassette.memory` yourself. + +## Options reference + +```ts +type RecordReplayOptions = { + mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay") + directory?: string // default: /test/fixtures/recordings + metadata?: Record // merged into cassette.metadata + redactor?: Redactor // default: Redactor.defaults() + dispatch?: "match" | "sequential" // default: "match" + match?: (incoming, recorded) => boolean // custom matcher +} +``` + +## Layout + +| File | Purpose | +| -------------- | -------------------------------------------------------------------------------- | +| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. | +| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. | +| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. | +| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. | +| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. | +| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. | +| `schema.ts` | Effect Schema definitions for the cassette JSON format. | +| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. | +| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. | diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts index 769bcc7c70d2..3897f0222c4e 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette.ts @@ -1,54 +1,76 @@ -import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect" +import { Context, Effect, FileSystem, Layer, Schema } from "effect" +import * as fs from "node:fs" import * as path from "node:path" -import { cassetteSecretFindings, type SecretFinding } from "./redaction" -import type { Cassette, CassetteMetadata, Interaction } from "./schema" -import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage" +import { secretFindings, type SecretFinding } from "./redaction" +import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema" -export interface Entry { - readonly name: string - readonly path: string +const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") + +export class CassetteNotFoundError extends Schema.TaggedErrorClass()("CassetteNotFoundError", { + cassetteName: Schema.String, +}) { + override get message() { + return `Cassette "${this.cassetteName}" not found` + } +} + +export interface AppendResult { + readonly findings: ReadonlyArray } export interface Interface { - readonly path: (name: string) => string - readonly read: (name: string) => Effect.Effect - readonly write: (name: string, cassette: Cassette) => Effect.Effect - readonly append: ( - name: string, - interaction: Interaction, - metadata: CassetteMetadata | undefined, - ) => Effect.Effect< - { - readonly cassette: Cassette - readonly findings: ReadonlyArray - }, - PlatformError.PlatformError - > + readonly read: (name: string) => Effect.Effect, CassetteNotFoundError> + readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect readonly exists: (name: string) => Effect.Effect - readonly list: () => Effect.Effect, PlatformError.PlatformError> - readonly scan: (cassette: Cassette) => ReadonlyArray + readonly list: () => Effect.Effect> } export class Service extends Context.Service()("@opencode-ai/http-recorder/Cassette") {} -export const layer = (options: { readonly directory?: string } = {}) => +export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => + fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`)) + +const buildCassette = ( + name: string, + interactions: ReadonlyArray, + metadata: CassetteMetadata | undefined, +): Cassette => ({ + version: 1, + metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) }, + interactions, +}) + +const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` + +const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw)) + +export const fileSystem = ( + options: { readonly directory?: string } = {}, +): Layer.Layer => Layer.effect( Service, Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem + const fs = yield* FileSystem.FileSystem const directory = options.directory ?? DEFAULT_RECORDINGS_DIR - const recorded = yield* Ref.make(new Map>()) + const recorded = new Map() + const directoriesEnsured = new Set() + + const cassettePath = (name: string) => path.join(directory, `${name}.json`) - const pathFor = (name: string) => cassettePath(name, directory) + const ensureDirectory = (name: string) => + Effect.gen(function* () { + const dir = path.dirname(cassettePath(name)) + if (directoriesEnsured.has(dir)) return + yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie) + directoriesEnsured.add(dir) + }) - const walk = (directory: string): Effect.Effect, PlatformError.PlatformError> => + const walk = (current: string): Effect.Effect> => Effect.gen(function* () { - const entries = yield* fileSystem - .readDirectory(directory) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[]))) const nested = yield* Effect.forEach(entries, (entry) => { - const full = path.join(directory, entry) - return fileSystem.stat(full).pipe( + const full = path.join(current, entry) + return fs.stat(full).pipe( Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))), Effect.catch(() => Effect.succeed([] as string[])), ) @@ -56,53 +78,73 @@ export const layer = (options: { readonly directory?: string } = {}) => return nested.flat() }) - const read = Effect.fn("Cassette.read")(function* (name: string) { - return parseCassette(yield* fileSystem.readFileString(pathFor(name))) + return Service.of({ + read: (name) => + fs.readFileString(cassettePath(name)).pipe( + Effect.map((raw) => parseCassette(raw).interactions), + Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))), + ), + append: (name, interaction, metadata) => + Effect.gen(function* () { + const entry = recorded.get(name) ?? { interactions: [], findings: [] } + if (!recorded.has(name)) recorded.set(name, entry) + entry.interactions.push(interaction) + entry.findings.push(...secretFindings(interaction)) + const cassette = buildCassette(name, entry.interactions, metadata) + const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})] + if (findings.length === 0) { + yield* ensureDirectory(name) + yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie) + } + return { findings } + }), + exists: (name) => + fs.access(cassettePath(name)).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ), + list: () => + walk(directory).pipe( + Effect.map((files) => + files + .filter((file) => file.endsWith(".json")) + .map((file) => + path + .relative(directory, file) + .replace(/\\/g, "/") + .replace(/\.json$/, ""), + ) + .toSorted((a, b) => a.localeCompare(b)), + ), + ), }) - - const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) { - yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true }) - yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette)) - }) - - const append = Effect.fn("Cassette.append")(function* ( - name: string, - interaction: Interaction, - metadata: CassetteMetadata | undefined, - ) { - const interactions = yield* Ref.updateAndGet(recorded, (previous) => - new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]), - ) - const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata) - const findings = cassetteSecretFindings(cassette) - if (findings.length === 0) yield* write(name, cassette) - return { cassette, findings } - }) - - const exists = Effect.fn("Cassette.exists")(function* (name: string) { - return yield* fileSystem.access(pathFor(name)).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ) - }) - - const list = Effect.fn("Cassette.list")(function* () { - return (yield* walk(directory)) - .filter((file) => file.endsWith(".json")) - .map((file) => ({ - name: path - .relative(directory, file) - .replace(/\\/g, "/") - .replace(/\.json$/, ""), - path: file, - })) - .toSorted((a, b) => a.name.localeCompare(b.name)) - }) - - return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings }) }), ) -export const defaultLayer = layer() +export const memory = (initial: Record> = {}): Layer.Layer => + Layer.sync(Service, () => { + const stored = new Map( + Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]), + ) + const accumulatedFindings = new Map() -export * as Cassette from "./cassette" + return Service.of({ + read: (name) => + stored.has(name) + ? Effect.succeed(stored.get(name) ?? []) + : Effect.fail(new CassetteNotFoundError({ cassetteName: name })), + append: (name, interaction, metadata) => + Effect.sync(() => { + const existing = stored.get(name) + if (existing) existing.push(interaction) + else stored.set(name, [interaction]) + const findings = accumulatedFindings.get(name) + if (findings) findings.push(...secretFindings(interaction)) + else accumulatedFindings.set(name, [...secretFindings(interaction)]) + if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata })) + return { findings: accumulatedFindings.get(name) ?? [] } + }), + exists: (name) => Effect.sync(() => stored.has(name)), + list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()), + }) + }) diff --git a/packages/http-recorder/src/diff.ts b/packages/http-recorder/src/diff.ts deleted file mode 100644 index 29517befcbd2..000000000000 --- a/packages/http-recorder/src/diff.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Option } from "effect" -import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http" -import { decodeJson } from "./matching" -import { REDACTED, redactUrl, secretFindings } from "./redaction" -import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema" - -const safeText = (value: unknown) => { - if (value === undefined) return "undefined" - if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) - const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value) - if (!text) return String(value) - return text.length > 300 ? `${text.slice(0, 300)}...` : text -} - -const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) - -const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray => { - if (Object.is(expected, received)) return [] - if ( - expected && - received && - typeof expected === "object" && - typeof received === "object" && - !Array.isArray(expected) && - !Array.isArray(received) - ) { - return [...new Set([...Object.keys(expected), ...Object.keys(received)])] - .toSorted() - .flatMap((key) => - valueDiffs( - (expected as Record)[key], - (received as Record)[key], - `${base}.${key}`, - limit, - ), - ) - .slice(0, limit) - } - if (Array.isArray(expected) && Array.isArray(received)) { - return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index) - .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit)) - .slice(0, limit) - } - return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`] -} - -const headerDiffs = (expected: Record, received: Record) => - [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => { - if (expected[key] === received[key]) return [] - if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`] - if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`] - return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`] - }) - -export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => { - const lines = [] - if (expected.method !== received.method) { - lines.push("method:", ` expected ${expected.method}, received ${received.method}`) - } - if (expected.url !== received.url) { - lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) - } - const headers = headerDiffs(expected.headers, received.headers) - if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) - const expectedBody = jsonBody(expected.body) - const receivedBody = jsonBody(received.body) - const body = - expectedBody !== undefined && receivedBody !== undefined - ? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`) - : expected.body === received.body - ? [] - : [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`] - if (body.length > 0) lines.push("body:", ...body) - return lines -} - -export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => { - const interactions = httpInteractions(cassette) - if (interactions.length === 0) return "cassette has no recorded HTTP interactions" - const ranked = interactions - .map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) })) - .toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index) - const best = ranked[0] - return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n") -} - -export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => - HttpClientRequest.makeWith( - request.method, - redactUrl(request.url), - UrlParams.empty, - Option.none(), - Headers.empty, - HttpBody.empty, - ) diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts index f103e45dc7b4..e6c3ccbc1540 100644 --- a/packages/http-recorder/src/effect.ts +++ b/packages/http-recorder/src/effect.ts @@ -1,62 +1,37 @@ import { NodeFileSystem } from "@effect/platform-node" -import { Effect, Layer, Option, Ref } from "effect" +import { Effect, Layer, Option } from "effect" import { FetchHttpClient, + Headers, + HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, + UrlParams, } from "effect/unstable/http" -import { redactedErrorRequest, mismatchDetail, requestDiff } from "./diff" -import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching" -import { redactHeaders, redactUrl, type SecretFinding } from "./redaction" -import { - httpInteractions, - type Cassette, - type CassetteMetadata, - type HttpInteraction, - type ResponseSnapshot, -} from "./schema" import * as CassetteService from "./cassette" +import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching" +import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" +import { defaults, type Redactor } from "./redactor" +import { redactUrl } from "./redaction" +import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema" -export const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] -const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] - -export type RecordReplayMode = "record" | "replay" | "passthrough" +export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough" export interface RecordReplayOptions { readonly mode?: RecordReplayMode readonly directory?: string readonly metadata?: CassetteMetadata - readonly redact?: { - readonly headers?: ReadonlyArray - readonly query?: ReadonlyArray - readonly url?: (url: string) => string - } - readonly requestHeaders?: ReadonlyArray - readonly responseHeaders?: ReadonlyArray - readonly redactBody?: (body: unknown) => unknown + readonly redactor?: Redactor readonly dispatch?: "match" | "sequential" readonly match?: RequestMatcher } -const responseHeaders = ( - response: HttpClientResponse.HttpClientResponse, - allow: ReadonlyArray, - redact: ReadonlyArray | undefined, -) => { - const merged = redactHeaders(response.headers as Record, allow, redact) - if (!merged["content-type"]) merged["content-type"] = "text/event-stream" - return merged -} - const BINARY_CONTENT_TYPES: ReadonlyArray = ["vnd.amazon.eventstream", "octet-stream"] -const isBinaryContentType = (contentType: string | undefined) => { - if (!contentType) return false - const lower = contentType.toLowerCase() - return BINARY_CONTENT_TYPES.some((token) => lower.includes(token)) -} +const isBinaryContentType = (contentType: string | undefined) => + contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token)) const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) => isBinaryContentType(contentType) @@ -68,34 +43,19 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co const decodeResponseBody = (snapshot: ResponseSnapshot) => snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body -const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) => - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request: redactedErrorRequest(request), - description: `Fixture "${name}" not found. Run with RECORD=true to create it.`, - }), - }) - -const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) => - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request: redactedErrorRequest(request), - description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`, - }), - }) +export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => + HttpClientRequest.makeWith( + request.method, + redactUrl(request.url), + UrlParams.empty, + Option.none(), + Headers.empty, + HttpBody.empty, + ) -const unsafeCassette = ( - request: HttpClientRequest.HttpClientRequest, - name: string, - findings: ReadonlyArray, -) => +const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) => new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request: redactedErrorRequest(request), - description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings - .map((item) => `${item.path} (${item.reason})`) - .join(", ")}`, - }), + reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }), }) export const recordingLayer = ( @@ -107,61 +67,22 @@ export const recordingLayer = ( Effect.gen(function* () { const upstream = yield* HttpClient.HttpClient const cassetteService = yield* CassetteService.Service - const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS - const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS + const redactor = options.redactor ?? defaults() const match = options.match ?? defaultMatcher - const mode = options.mode ?? "replay" + const requested = options.mode ?? "auto" + const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested const sequential = options.dispatch === "sequential" - const replay = yield* Ref.make(undefined) - const cursor = yield* Ref.make(0) + const replay = yield* makeReplayState(cassetteService, name, httpInteractions) const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) - const raw = yield* Effect.promise(() => web.text()) - const body = options.redactBody - ? Option.match(decodeJson(raw), { - onNone: () => raw, - onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)), - }) - : raw - return { + return redactor.request({ method: web.method, - url: redactUrl(web.url, options.redact?.query, options.redact?.url), - headers: redactHeaders( - Object.fromEntries(web.headers.entries()), - requestHeadersAllow, - options.redact?.headers, - ), - body, - } - }) - - const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) => - Effect.gen(function* () { - const interactions = httpInteractions(cassette) - if (sequential) { - const index = yield* Ref.get(cursor) - const interaction = interactions[index] - if (!interaction) - return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` } - if (!match(incoming, interaction.request)) { - return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") } - } - yield* Ref.update(cursor, (n) => n + 1) - return { interaction, detail: "" } - } - const interaction = interactions.find((candidate) => match(incoming, candidate.request)) - return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) } - }) - - const loadReplay = (request: HttpClientRequest.HttpClientRequest) => - Effect.gen(function* () { - const cached = yield* Ref.get(replay) - if (cached) return cached - const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name))) - yield* Ref.set(replay, cassette) - return cassette + url: web.url, + headers: Object.fromEntries(web.headers.entries()), + body: yield* Effect.promise(() => web.text()), + }) }) return HttpClient.make((request) => { @@ -169,18 +90,21 @@ export const recordingLayer = ( if (mode === "record") { return Effect.gen(function* () { - const currentRequest = yield* snapshotRequest(request) + const incoming = yield* snapshotRequest(request) const response = yield* upstream.execute(request) - const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers) - const captured = yield* captureResponseBody(response, headers["content-type"]) + const captured = yield* captureResponseBody(response, response.headers["content-type"]) const interaction: HttpInteraction = { transport: "http", - request: currentRequest, - response: { status: response.status, headers, ...captured }, + request: incoming, + response: redactor.response({ + status: response.status, + headers: response.headers as Record, + ...captured, + }), } - const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie) - const findings = result.findings - if (findings.length > 0) return yield* unsafeCassette(request, name, findings) + yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe( + Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))), + ) return HttpClientResponse.fromWeb( request, new Response(decodeResponseBody(interaction.response), interaction.response), @@ -189,14 +113,23 @@ export const recordingLayer = ( } return Effect.gen(function* () { - const cassette = yield* loadReplay(request) const incoming = yield* snapshotRequest(request) - const { interaction, detail } = yield* selectInteraction(cassette, incoming) - if (!interaction) return yield* fixtureMismatch(request, name, detail) - + const interactions = yield* replay.load.pipe( + Effect.mapError(() => + transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`), + ), + ) + const result = sequential + ? selectSequential(interactions, incoming, match, yield* replay.cursor) + : selectMatch(interactions, incoming, match) + if (!result.interaction) + return yield* Effect.fail( + transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), + ) + if (sequential) yield* replay.advance return HttpClientResponse.fromWeb( request, - new Response(decodeResponseBody(interaction.response), interaction.response), + new Response(decodeResponseBody(result.interaction.response), result.interaction.response), ) }) }) @@ -205,7 +138,7 @@ export const recordingLayer = ( export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer => recordingLayer(name, options).pipe( - Layer.provide(CassetteService.layer({ directory: options.directory })), + Layer.provide(CassetteService.fileSystem({ directory: options.directory })), Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer), ) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts index d85e13bf4c0b..4b47e4513d42 100644 --- a/packages/http-recorder/src/index.ts +++ b/packages/http-recorder/src/index.ts @@ -1,10 +1,26 @@ -export * from "./schema" -export * from "./redaction" -export * from "./matching" -export * from "./diff" -export * from "./storage" -export * from "./websocket" -export * from "./effect" +export type { + CassetteMetadata, + HttpInteraction, + Interaction, + RequestSnapshot, + ResponseSnapshot, + WebSocketFrame, + WebSocketInteraction, +} from "./schema" +export { CassetteNotFoundError, hasCassetteSync } from "./cassette" +export { defaultMatcher, type RequestMatcher } from "./matching" +export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction" +export { UnsafeCassetteError } from "./recorder" +export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect" +export { + makeWebSocketExecutor, + type WebSocketConnection, + type WebSocketExecutor, + type WebSocketRecordReplayOptions, + type WebSocketRequest, +} from "./websocket" + export * as Cassette from "./cassette" +export * as Redactor from "./redactor" export * as HttpRecorder from "." diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/matching.ts index b66c8fd14677..9af85a2f3ad4 100644 --- a/packages/http-recorder/src/matching.ts +++ b/packages/http-recorder/src/matching.ts @@ -1,5 +1,6 @@ import { Option, Schema } from "effect" -import type { RequestSnapshot } from "./schema" +import { REDACTED, secretFindings } from "./redaction" +import type { HttpInteraction, RequestSnapshot } from "./schema" const JsonValue = Schema.fromJsonString(Schema.Unknown) export const decodeJson = Schema.decodeUnknownOption(JsonValue) @@ -34,3 +35,90 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string => export const defaultMatcher: RequestMatcher = (incoming, recorded) => canonicalSnapshot(incoming) === canonicalSnapshot(recorded) + +const safeText = (value: unknown) => { + if (value === undefined) return "undefined" + if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) + const text = JSON.stringify(value) + if (!text) return String(value) + return text.length > 300 ? `${text.slice(0, 300)}...` : text +} + +const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) + +const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray => { + if (Object.is(expected, received)) return [] + if (isRecord(expected) && isRecord(received)) { + return [...new Set([...Object.keys(expected), ...Object.keys(received)])] + .toSorted() + .flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit)) + .slice(0, limit) + } + if (Array.isArray(expected) && Array.isArray(received)) { + return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index) + .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit)) + .slice(0, limit) + } + return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`] +} + +const headerDiffs = (expected: Record, received: Record) => + [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => { + if (expected[key] === received[key]) return [] + if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`] + if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`] + return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`] + }) + +export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray => { + const lines: string[] = [] + if (expected.method !== received.method) { + lines.push("method:", ` expected ${expected.method}, received ${received.method}`) + } + if (expected.url !== received.url) { + lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) + } + const headers = headerDiffs(expected.headers, received.headers) + if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) + const expectedBody = jsonBody(expected.body) + const receivedBody = jsonBody(received.body) + const body = + expectedBody !== undefined && receivedBody !== undefined + ? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`) + : expected.body === received.body + ? [] + : [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`] + if (body.length > 0) lines.push("body:", ...body) + return lines +} + +export const mismatchDetail = (interactions: ReadonlyArray, incoming: RequestSnapshot): string => { + if (interactions.length === 0) return "cassette has no recorded HTTP interactions" + const ranked = interactions + .map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) })) + .toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index) + const best = ranked[0] + return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n") +} + +export const selectMatch = ( + interactions: ReadonlyArray, + incoming: RequestSnapshot, + match: RequestMatcher, +): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { + const interaction = interactions.find((candidate) => match(incoming, candidate.request)) + return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) } +} + +export const selectSequential = ( + interactions: ReadonlyArray, + incoming: RequestSnapshot, + match: RequestMatcher, + index: number, +): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { + const interaction = interactions[index] + if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` } + if (!match(incoming, interaction.request)) + return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") } + return { interaction, detail: "" } +} diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts new file mode 100644 index 000000000000..460b427c2a03 --- /dev/null +++ b/packages/http-recorder/src/recorder.ts @@ -0,0 +1,73 @@ +import { Effect, Ref, Schema, Scope } from "effect" +import type * as CassetteService from "./cassette" +import type { CassetteNotFoundError } from "./cassette" +import { SecretFindingSchema } from "./redaction" +import type { CassetteMetadata, Interaction } from "./schema" + +export class UnsafeCassetteError extends Schema.TaggedErrorClass()("UnsafeCassetteError", { + cassetteName: Schema.String, + findings: Schema.Array(SecretFindingSchema), +}) { + override get message() { + return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings + .map((finding) => `${finding.path} (${finding.reason})`) + .join(", ")}` + } +} + +export type ResolvedMode = "record" | "replay" | "passthrough" + +const isCI = () => { + const value = process.env.CI + return value !== undefined && value !== "" && value !== "false" && value !== "0" +} + +export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect => + Effect.gen(function* () { + if (isCI()) return "replay" + return (yield* cassette.exists(name)) ? "replay" : "record" + }) + +export const appendOrFail = ( + cassette: CassetteService.Interface, + name: string, + interaction: Interaction, + metadata: CassetteMetadata | undefined, +): Effect.Effect => + cassette + .append(name, interaction, metadata) + .pipe( + Effect.flatMap(({ findings }) => + findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })), + ), + ) + +export interface ReplayState { + readonly load: Effect.Effect, CassetteNotFoundError> + readonly cursor: Effect.Effect + readonly advance: Effect.Effect +} + +export const makeReplayState = ( + cassette: CassetteService.Interface, + name: string, + project: (interactions: ReadonlyArray) => ReadonlyArray, +): Effect.Effect, never, Scope.Scope> => + Effect.gen(function* () { + const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) + const position = yield* Ref.make(0) + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const used = yield* Ref.get(position) + if (used === 0) return + const interactions = yield* load.pipe(Effect.orDie) + if (used < interactions.length) + yield* Effect.die( + new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`), + ) + }), + ) + + return { load, cursor: Ref.get(position), advance: Ref.update(position, (n) => n + 1) } + }) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts index 3a8b0978397f..b6aa8b3b87b6 100644 --- a/packages/http-recorder/src/redaction.ts +++ b/packages/http-recorder/src/redaction.ts @@ -1,5 +1,3 @@ -import type { Cassette } from "./schema" - export const REDACTED = "[REDACTED]" const DEFAULT_REDACT_HEADERS = [ @@ -97,10 +95,13 @@ export const redactHeaders = ( ) } -export type SecretFinding = { - readonly path: string - readonly reason: string -} +import { Schema } from "effect" + +export const SecretFindingSchema = Schema.Struct({ + path: Schema.String, + reason: Schema.String, +}) +export type SecretFinding = Schema.Schema.Type export const secretFindings = (value: unknown): ReadonlyArray => stringEntries(value).flatMap((entry) => [ @@ -112,5 +113,3 @@ export const secretFindings = (value: unknown): ReadonlyArray => .filter((item) => entry.value.includes(item.value)) .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), ]) - -export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette) diff --git a/packages/http-recorder/src/redactor.ts b/packages/http-recorder/src/redactor.ts new file mode 100644 index 000000000000..917ab05d09f6 --- /dev/null +++ b/packages/http-recorder/src/redactor.ts @@ -0,0 +1,76 @@ +import { Option } from "effect" +import { decodeJson } from "./matching" +import { redactHeaders, redactUrl } from "./redaction" +import type { RequestSnapshot, ResponseSnapshot } from "./schema" + +export const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] +export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] + +const identity = (value: T) => value + +export interface Redactor { + readonly request: (snapshot: RequestSnapshot) => RequestSnapshot + readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot +} + +export const compose = (...redactors: ReadonlyArray>): Redactor => { + const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined) + const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined) + return { + request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot), + response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot), + } +} + +export interface HeaderOptions { + readonly allow?: ReadonlyArray + readonly redact?: ReadonlyArray +} + +export const requestHeaders = (options: HeaderOptions = {}): Partial => ({ + request: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact), + }), +}) + +export const responseHeaders = (options: HeaderOptions = {}): Partial => ({ + response: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact), + }), +}) + +export interface UrlOptions { + readonly query?: ReadonlyArray + readonly transform?: (url: string) => string +} + +export const url = (options: UrlOptions = {}): Partial => ({ + request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }), +}) + +export const body = (transform: (parsed: unknown) => unknown): Partial => ({ + request: (snapshot) => ({ + ...snapshot, + body: Option.match(decodeJson(snapshot.body), { + onNone: () => snapshot.body, + onSome: (parsed) => JSON.stringify(transform(parsed)), + }), + }), +}) + +export interface DefaultRedactorOverrides { + readonly requestHeaders?: HeaderOptions + readonly responseHeaders?: HeaderOptions + readonly url?: UrlOptions + readonly body?: (parsed: unknown) => unknown +} + +export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor => + compose( + requestHeaders(overrides.requestHeaders), + responseHeaders(overrides.responseHeaders), + url(overrides.url), + ...(overrides.body ? [body(overrides.body)] : []), + ) diff --git a/packages/http-recorder/src/schema.ts b/packages/http-recorder/src/schema.ts index 2692b525b4ac..113769c7b7fc 100644 --- a/packages/http-recorder/src/schema.ts +++ b/packages/http-recorder/src/schema.ts @@ -52,9 +52,10 @@ export const isHttpInteraction = InteractionSchema.guards.http export const isWebSocketInteraction = InteractionSchema.guards.websocket -export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction) +export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) -export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction) +export const webSocketInteractions = (interactions: ReadonlyArray) => + interactions.filter(isWebSocketInteraction) export const CassetteSchema = Schema.Struct({ version: Schema.Literal(1), diff --git a/packages/http-recorder/src/storage.ts b/packages/http-recorder/src/storage.ts deleted file mode 100644 index 08dadb1bb9a7..000000000000 --- a/packages/http-recorder/src/storage.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Option } from "effect" -import * as fs from "node:fs" -import * as path from "node:path" -import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema" - -export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") - -export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`) - -export const metadataFor = (name: string, metadata: CassetteMetadata | undefined): CassetteMetadata => ({ - name, - recordedAt: new Date().toISOString(), - ...(metadata ?? {}), -}) - -export const cassetteFor = ( - name: string, - interactions: ReadonlyArray, - metadata: CassetteMetadata | undefined, -): Cassette => ({ - version: 1, - metadata: metadataFor(name, metadata), - interactions, -}) - -export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` - -export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw)) - -export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => { - const file = cassettePath(name, options.directory) - if (!fs.existsSync(file)) return false - return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8"))) -} diff --git a/packages/http-recorder/src/websocket.ts b/packages/http-recorder/src/websocket.ts index 8a854cb62c67..f7529b488809 100644 --- a/packages/http-recorder/src/websocket.ts +++ b/packages/http-recorder/src/websocket.ts @@ -2,10 +2,10 @@ import { Effect, Option, Ref, Scope, Stream } from "effect" import type { Headers } from "effect/unstable/http" import * as CassetteService from "./cassette" import { canonicalizeJson, decodeJson } from "./matching" -import { redactHeaders, redactUrl, type SecretFinding } from "./redaction" -import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema" - -export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] +import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" +import type { RecordReplayMode } from "./effect" +import { defaults, type Redactor } from "./redactor" +import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema" export interface WebSocketRequest { readonly url: string @@ -24,67 +24,36 @@ export interface WebSocketExecutor { export interface WebSocketRecordReplayOptions { readonly name: string - readonly mode?: "record" | "replay" | "passthrough" + readonly mode?: RecordReplayMode readonly metadata?: CassetteMetadata readonly cassette: CassetteService.Interface readonly live: WebSocketExecutor - readonly redact?: { - readonly headers?: ReadonlyArray - readonly query?: ReadonlyArray - readonly url?: (url: string) => string - } - readonly requestHeaders?: ReadonlyArray + readonly redactor?: Redactor readonly compareClientMessagesAsJson?: boolean } -const headersRecord = (headers: Headers.Headers) => +const headersRecord = (headers: Headers.Headers): Record => Object.fromEntries( - Object.entries(headers as Record) - .filter((entry): entry is [string, string] => typeof entry[1] === "string") - .toSorted(([a], [b]) => a.localeCompare(b)), + Object.entries(headers as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), ) -const openSnapshot = ( - request: WebSocketRequest, - options: Pick, "redact" | "requestHeaders"> = {}, -) => ({ - url: redactUrl(request.url, options.redact?.query, options.redact?.url), - headers: redactHeaders( - headersRecord(request.headers), - options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS, - options.redact?.headers, - ), -}) - -const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body }) - -const frameText = (frame: WebSocketFrame) => { - if (frame.kind === "text") return frame.body - return new TextDecoder().decode(Buffer.from(frame.body, "base64")) -} - -const frameMessage = (frame: WebSocketFrame) => - frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64")) - -const receivedFrame = (message: string | Uint8Array): WebSocketFrame => +const encodeFrame = (message: string | Uint8Array): WebSocketFrame => typeof message === "string" - ? textFrame(message) + ? { kind: "text", body: message } : { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } -const unsafeCassette = (name: string, findings: ReadonlyArray) => - new Error( - `Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings - .map((item) => `${item.path} (${item.reason})`) - .join(", ")}`, - ) +const decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array => + frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64")) -const mismatch = (message: string, actual: unknown, expected: unknown) => - new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`) +const decodeFrameText = (frame: WebSocketFrame) => + frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64")) const assertEqual = (message: string, actual: unknown, expected: unknown) => Effect.sync(() => { if (JSON.stringify(actual) === JSON.stringify(expected)) return - throw mismatch(message, actual, expected) + throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`) }) const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson }) @@ -94,7 +63,7 @@ const compareClientMessage = (actual: string, expected: WebSocketFrame | undefin return Effect.sync(() => { throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`) }) - const expectedText = frameText(expected) + const expectedText = decodeFrameText(expected) if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText) return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText)) } @@ -103,7 +72,18 @@ export const makeWebSocketExecutor = ( options: WebSocketRecordReplayOptions, ): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { - const mode = options.mode ?? "replay" + const requested = options.mode ?? "auto" + const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested + const redactor = options.redactor ?? defaults() + const openSnapshot = (request: WebSocketRequest) => { + const redacted = redactor.request({ + method: "GET", + url: request.url, + headers: headersRecord(request.headers), + body: "", + }) + return { url: redacted.url, headers: redacted.headers } + } if (mode === "passthrough") return options.live @@ -118,21 +98,21 @@ export const makeWebSocketExecutor = ( const closeOnce = Effect.gen(function* () { if (yield* Ref.getAndSet(closed, true)) return yield* connection.close - const result = yield* options.cassette - .append( - options.name, - { transport: "websocket", open: openSnapshot(request, options), client, server }, - options.metadata, - ) - .pipe(Effect.orDie) - if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings)) + yield* appendOrFail( + options.cassette, + options.name, + { transport: "websocket", open: openSnapshot(request), client, server }, + options.metadata, + ).pipe(Effect.orDie) }) return { - sendText: (message: string) => - connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))), + sendText: (message) => + connection + .sendText(message) + .pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))), messages: connection.messages.pipe( Stream.map((message) => { - server.push(receivedFrame(message)) + server.push(encodeFrame(message)) return message }), ), @@ -142,44 +122,20 @@ export const makeWebSocketExecutor = ( } } - const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray } | undefined>( - undefined, - ) - const cursor = yield* Ref.make(0) - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const input = yield* Ref.get(replay) - if (!input) return - yield* assertEqual( - `Unused recorded WebSocket interactions in ${options.name}`, - yield* Ref.get(cursor), - input.interactions.length, - ) - }), - ) - - const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () { - const cached = yield* Ref.get(replay) - if (cached) return cached - const input = { - interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)), - } - yield* Ref.set(replay, input) - return input - }) + const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions) return { - open: (request) => { - return Effect.gen(function* () { - const input = yield* loadReplay() - const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1) - const interaction = input.interactions[index] + open: (request) => + Effect.gen(function* () { + const interactions = yield* replay.load.pipe(Effect.orDie) + const index = yield* replay.cursor + const interaction = interactions[index] if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`)) - yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open) + yield* replay.advance + yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open) const messageIndex = yield* Ref.make(0) return { - sendText: (message: string) => + sendText: (message) => Effect.gen(function* () { const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1) yield* compareClientMessage( @@ -189,7 +145,7 @@ export const makeWebSocketExecutor = ( options.compareClientMessagesAsJson === true, ) }), - messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)), + messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)), close: Effect.gen(function* () { yield* assertEqual( `WebSocket client frame count for interaction ${index + 1}`, @@ -198,7 +154,6 @@ export const makeWebSocketExecutor = ( ) }), } - }) - }, + }), } }) diff --git a/packages/http-recorder/sst-env.d.ts b/packages/http-recorder/sst-env.d.ts new file mode 100644 index 000000000000..64441936d7a0 --- /dev/null +++ b/packages/http-recorder/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts index 676422e6a492..7613563fd055 100644 --- a/packages/http-recorder/test/record-replay.test.ts +++ b/packages/http-recorder/test/record-replay.test.ts @@ -6,7 +6,16 @@ import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" import { HttpRecorder } from "../src" -import { redactedErrorRequest } from "../src/diff" +import { redactedErrorRequest } from "../src/effect" +import type { Interaction } from "../src/schema" + +const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => + Effect.runPromise( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) + }).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)), + ) const post = (url: string, body: object) => Effect.gen(function* () { @@ -33,7 +42,7 @@ const runRecorder = (effect: Effect.Effect { test("detects secret-looking values without returning the secret", () => { expect( - HttpRecorder.cassetteSecretFindings({ + HttpRecorder.secretFindings({ version: 1, interactions: [ { @@ -136,7 +145,7 @@ describe("http-recorder", () => { test("detects secret-looking values inside metadata", () => { expect( - HttpRecorder.cassetteSecretFindings({ + HttpRecorder.secretFindings({ version: 1, metadata: { token: "sk-123456789012345678901234" }, interactions: [], @@ -144,60 +153,42 @@ describe("http-recorder", () => { ).toEqual([{ path: "metadata.token", reason: "API key" }]) }) - test("formats websocket cassettes with shared metadata", () => { - const cassette = HttpRecorder.cassetteFor( - "websocket/basic", - [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], - server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], - }, - ], - { provider: "openai" }, - ) - - expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" }) - expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette) - }) + test("replays websocket interactions seeded into the in-memory cassette adapter", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + const executor = yield* HttpRecorder.makeWebSocketExecutor({ + name: "websocket/replay", + cassette, + compareClientMessagesAsJson: true, + live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) }, + }) + const connection = yield* executor.open({ + url: "wss://example.test/realtime", + headers: Headers.fromInput({ "content-type": "application/json" }), + }) + yield* connection.sendText(JSON.stringify({ type: "response.create" })) + const messages: Array = [] + yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message)))) + yield* connection.close - test("replays websocket interactions from the shared cassette service", async () => { - await runRecorder( - Effect.gen(function* () { - const cassette = yield* HttpRecorder.Cassette.Service - yield* cassette.write( - "websocket/replay", - HttpRecorder.cassetteFor( - "websocket/replay", - [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], - server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], - }, - ], - undefined, + expect(messages).toEqual([JSON.stringify({ type: "response.completed" })]) + }).pipe( + Effect.provide( + HttpRecorder.Cassette.memory({ + "websocket/replay": [ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ], + }), ), - ) - const executor = yield* HttpRecorder.makeWebSocketExecutor({ - name: "websocket/replay", - cassette, - compareClientMessagesAsJson: true, - live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) }, - }) - const connection = yield* executor.open({ - url: "wss://example.test/realtime", - headers: Headers.fromInput({ "content-type": "application/json" }), - }) - yield* connection.sendText(JSON.stringify({ type: "response.create" })) - const messages: Array = [] - yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message)))) - yield* connection.close - - expect(messages).toEqual([JSON.stringify({ type: "response.completed" })]) - }), + ), + ), ) }) @@ -227,17 +218,14 @@ describe("http-recorder", () => { yield* connection.messages.pipe(Stream.runDrain) yield* connection.close - expect(yield* cassette.read("websocket/record")).toMatchObject({ - metadata: { name: "websocket/record", provider: "test" }, - interactions: [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], - server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], - }, - ], - }) + expect(yield* cassette.read("websocket/record")).toMatchObject([ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ]) }), ) }) @@ -300,6 +288,49 @@ describe("http-recorder", () => { ) }) + test("auto mode replays when the cassette exists", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-")) + await seedCassetteDirectory(directory, "auto-replay", [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/echo", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ step: 1 }), + }, + response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' }, + }, + ]) + + const result = await runWith( + "auto-replay", + { directory, mode: "auto" }, + post("https://example.test/echo", { step: 1 }), + ) + expect(result).toBe('{"reply":"hi"}') + }) + + test("auto mode forces replay when CI=true even if cassette is missing", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-")) + const previous = process.env.CI + process.env.CI = "true" + try { + const exit = await Effect.runPromise( + Effect.exit( + post("https://example.test/echo", { step: 1 }).pipe( + Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain('Fixture "missing-cassette" not found') + } finally { + if (previous === undefined) delete process.env.CI + else process.env.CI = previous + } + }) + test("mismatch diagnostics show closest redacted request differences", async () => { await run( Effect.gen(function* () { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt index 1a4ae93fb6c4..078b1262f023 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt @@ -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(src) + val imported = json.decodeFromString(src.replace("ses_abc", migrated)) + val legacy = json.decodeFromString(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 = """{ diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts index 2c28d3ce74df..5b84b152a4ff 100644 --- a/packages/llm/example/tutorial.ts +++ b/packages/llm/example/tutorial.ts @@ -184,7 +184,7 @@ const FakeProtocol = Protocol.make({ stream: { event: Schema.String, initial: () => undefined, - step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", text: frame }]] as const), + step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const), onHalt: () => [{ type: "request-finish", reason: "stop" }], }, }) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index ff2239c0d76a..a426807c0287 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -5,10 +5,10 @@ import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type CacheHint, type FinishReason, - type LLMEvent, type LLMRequest, type ProviderMetadata, type ToolCallPart, @@ -16,6 +16,7 @@ import { type ToolResultPart, } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import * as Cache from "./utils/cache" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "anthropic-messages" @@ -25,7 +26,10 @@ export const PATH = "/messages" // ============================================================================= // Request Body Schema // ============================================================================= -const AnthropicCacheControl = Schema.Struct({ type: Schema.tag("ephemeral") }) +const AnthropicCacheControl = Schema.Struct({ + type: Schema.tag("ephemeral"), + ttl: Schema.optional(Schema.Literals(["5m", "1h"])), +}) const AnthropicTextBlock = Schema.Struct({ type: Schema.tag("text"), @@ -193,8 +197,24 @@ const invalid = ProviderShared.invalidRequest // ============================================================================= // Request Lowering // ============================================================================= -const cacheControl = (cache: CacheHint | undefined) => - cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined +// Anthropic accepts at most 4 explicit cache_control breakpoints per request, +// across `tools`, `system`, and `messages`. Beyond the cap the API returns a +// 400 — so the lowering layer counts emitted markers and silently drops any +// that exceed it. +const ANTHROPIC_BREAKPOINT_CAP = 4 + +const EPHEMERAL_5M = { type: "ephemeral" as const } +const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const } + +const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => { + if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined + if (breakpoints.remaining <= 0) { + breakpoints.dropped += 1 + return undefined + } + breakpoints.remaining -= 1 + return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M +} const anthropicMetadata = (metadata: Record): ProviderMetadata => ({ anthropic: metadata }) @@ -204,10 +224,11 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | return typeof anthropic.signature === "string" ? anthropic.signature : undefined } -const lowerTool = (tool: ToolDefinition): AnthropicTool => ({ +const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({ name: tool.name, description: tool.description, input_schema: tool.inputSchema, + cache_control: cacheControl(breakpoints, tool.cache), }) const lowerToolChoice = (toolChoice: NonNullable) => @@ -249,7 +270,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock }) -const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (request: LLMRequest) { +const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( + request: LLMRequest, + breakpoints: Cache.Breakpoints, +) { const messages: AnthropicMessage[] = [] for (const message of request.messages) { @@ -258,7 +282,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re for (const part of message.content) { if (!ProviderShared.supportsContent(part, ["text"])) return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"]) - content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) }) + content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) } messages.push({ role: "user", content }) continue @@ -268,7 +292,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re const content: AnthropicAssistantBlock[] = [] for (const part of message.content) { if (part.type === "text") { - content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) }) + content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) continue } if (part.type === "reasoning") { @@ -304,6 +328,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re tool_use_id: part.id, content: ProviderShared.toolResultText(part), is_error: part.result.type === "error" ? true : undefined, + cache_control: cacheControl(breakpoints, part.cache), }) } messages.push({ role: "user", content }) @@ -330,18 +355,33 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation + // Allocate the 4-breakpoint budget in invalidation order: tools → system → + // messages. Tools live highest in the cache hierarchy, so when callers + // over-mark we keep their tool hints and shed the message-tail ones first. + const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) + const tools = + request.tools.length === 0 || request.toolChoice?.type === "none" + ? undefined + : request.tools.map((tool) => lowerTool(breakpoints, tool)) + const system = + request.system.length === 0 + ? undefined + : request.system.map((part) => ({ + type: "text" as const, + text: part.text, + cache_control: cacheControl(breakpoints, part.cache), + })) + const messages = yield* lowerMessages(request, breakpoints) + if (breakpoints.dropped > 0) { + yield* Effect.logWarning( + `Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`, + ) + } return { model: request.model.id, - system: - request.system.length === 0 - ? undefined - : request.system.map((part) => ({ - type: "text" as const, - text: part.text, - cache_control: cacheControl(part.cache), - })), - messages: yield* lowerMessages(request), - tools: request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined : request.tools.map(lowerTool), + system, + messages, + tools, tool_choice: toolChoice, stream: true as const, max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096, @@ -415,14 +455,13 @@ const serverToolResultEvent = (block: NonNullable).type) : "" const isError = errorPayload.endsWith("_tool_result_error") - return { - type: "tool-result", + return LLMEvent.toolResult({ id: block.tool_use_id ?? "", name: SERVER_TOOL_RESULT_NAMES[block.type], result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content }, providerExecuted: true, providerMetadata: anthropicMetadata({ blockType: block.type }), - } + }) } type StepResult = readonly [ParserState, ReadonlyArray] @@ -453,18 +492,17 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes } if (block.type === "text" && block.text) { - return [state, [{ type: "text-delta", text: block.text }]] + return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]] } if (block.type === "thinking" && block.thinking) { return [ state, [ - { - type: "reasoning-delta", + LLMEvent.reasoningDelta({ + id: `reasoning-${event.index ?? 0}`, text: block.thinking, - ...(block.signature ? { providerMetadata: anthropicMetadata({ signature: block.signature }) } : {}), - }, + }), ], ] } @@ -480,17 +518,25 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f const delta = event.delta if (delta?.type === "text_delta" && delta.text) { - return [state, [{ type: "text-delta", text: delta.text }]] satisfies StepResult + return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult } if (delta?.type === "thinking_delta" && delta.thinking) { - return [state, [{ type: "reasoning-delta", text: delta.thinking }]] satisfies StepResult + return [ + state, + [LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })], + ] satisfies StepResult } if (delta?.type === "signature_delta" && delta.signature) { return [ state, - [{ type: "reasoning-delta", text: "", providerMetadata: anthropicMetadata({ signature: delta.signature }) }], + [ + LLMEvent.reasoningEnd({ + id: `reasoning-${event.index ?? 0}`, + providerMetadata: anthropicMetadata({ signature: delta.signature }), + }), + ], ] satisfies StepResult } @@ -524,21 +570,20 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult = return [ { ...state, usage }, [ - { - type: "request-finish", + LLMEvent.requestFinish({ reason: mapFinishReason(event.delta?.stop_reason), usage, - ...(event.delta?.stop_sequence - ? { providerMetadata: anthropicMetadata({ stopSequence: event.delta.stop_sequence }) } - : {}), - }, + providerMetadata: event.delta?.stop_sequence + ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) + : undefined, + }), ], ] } const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ state, - [{ type: "provider-error", message: event.error?.message ?? "Anthropic Messages stream error" }], + [LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })], ] const step = (state: ParserState, event: AnthropicEvent) => { diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 09176104dfbc..e2ba1ff3bea1 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -3,10 +3,10 @@ import { Route, type RouteModelInput } from "../route/client" import { Endpoint } from "../route/endpoint" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type CacheHint, type FinishReason, - type LLMEvent, type LLMRequest, type ToolCallPart, type ToolDefinition, @@ -108,7 +108,7 @@ type BedrockMessage = Schema.Schema.Type const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock]) type BedrockSystemBlock = Schema.Schema.Type -const BedrockTool = Schema.Struct({ +const BedrockToolSpec = Schema.Struct({ toolSpec: Schema.Struct({ name: Schema.String, description: Schema.String, @@ -117,6 +117,9 @@ const BedrockTool = Schema.Struct({ }), }), }) +type BedrockToolSpec = Schema.Schema.Type + +const BedrockTool = Schema.Union([BedrockToolSpec, BedrockCache.CachePointBlock]) type BedrockTool = Schema.Schema.Type const BedrockToolChoice = Schema.Union([ @@ -214,7 +217,7 @@ type BedrockEvent = Schema.Schema.Type // ============================================================================= // Request Lowering // ============================================================================= -const lowerTool = (tool: ToolDefinition): BedrockTool => ({ +const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({ toolSpec: { name: tool.name, description: tool.description, @@ -222,11 +225,22 @@ const lowerTool = (tool: ToolDefinition): BedrockTool => ({ }, }) +const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray): BedrockTool[] => { + const result: BedrockTool[] = [] + for (const tool of tools) { + result.push(lowerToolSpec(tool)) + const cachePoint = BedrockCache.block(breakpoints, tool.cache) + if (cachePoint) result.push(cachePoint) + } + return result +} + const textWithCache = ( + breakpoints: BedrockCache.Breakpoints, text: string, cache: CacheHint | undefined, ): Array => { - const cachePoint = BedrockCache.block(cache) + const cachePoint = BedrockCache.block(breakpoints, cache) return cachePoint ? [{ text }, cachePoint] : [{ text }] } @@ -257,7 +271,10 @@ const lowerToolResult = (part: ToolResultPart): BedrockToolResultBlock => ({ }, }) -const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (request: LLMRequest) { +const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* ( + request: LLMRequest, + breakpoints: BedrockCache.Breakpoints, +) { const messages: BedrockMessage[] = [] for (const message of request.messages) { @@ -267,7 +284,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ if (!ProviderShared.supportsContent(part, ["text", "media"])) return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"]) if (part.type === "text") { - content.push(...textWithCache(part.text, part.cache)) + content.push(...textWithCache(breakpoints, part.text, part.cache)) continue } if (part.type === "media") { @@ -289,7 +306,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ "tool-call", ]) if (part.type === "text") { - content.push(...textWithCache(part.text, part.cache)) + content.push(...textWithCache(breakpoints, part.text, part.cache)) continue } if (part.type === "reasoning") { @@ -309,11 +326,13 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ continue } - const content: BedrockToolResultBlock[] = [] + const content: BedrockUserBlock[] = [] for (const part of message.content) { if (!ProviderShared.supportsContent(part, ["tool-result"])) return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"]) content.push(lowerToolResult(part)) + const cachePoint = BedrockCache.block(breakpoints, part.cache) + if (cachePoint) content.push(cachePoint) } messages.push({ role: "user", content }) } @@ -323,16 +342,32 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ // System prompts share the cache-point convention: emit the text block, then // optionally a positional `cachePoint` marker. -const lowerSystem = (system: ReadonlyArray): BedrockSystemBlock[] => - system.flatMap((part) => textWithCache(part.text, part.cache)) +const lowerSystem = ( + breakpoints: BedrockCache.Breakpoints, + system: ReadonlyArray, +): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache)) const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation + // Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in + // tools → system → messages order to favour the highest-impact prefixes. + const breakpoints = BedrockCache.breakpoints() + const toolConfig = + request.tools.length > 0 && request.toolChoice?.type !== "none" + ? { tools: lowerTools(breakpoints, request.tools), toolChoice } + : undefined + const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system) + const messages = yield* lowerMessages(request, breakpoints) + if (breakpoints.dropped > 0) { + yield* Effect.logWarning( + `Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`, + ) + } return { modelId: request.model.id, - messages: yield* lowerMessages(request), - system: request.system.length === 0 ? undefined : lowerSystem(request.system), + messages, + system, inferenceConfig: generation?.maxTokens === undefined && generation?.temperature === undefined && @@ -345,10 +380,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: topP: generation?.topP, stopSequences: generation?.stop, }, - toolConfig: - request.tools.length > 0 && request.toolChoice?.type !== "none" - ? { tools: request.tools.map(lowerTool), toolChoice } - : undefined, + toolConfig, } }) @@ -400,13 +432,26 @@ const step = (state: ParserState, event: BedrockEvent) => } if (event.contentBlockDelta?.delta?.text) { - return [state, [{ type: "text-delta" as const, text: event.contentBlockDelta.delta.text }]] as const + return [ + state, + [ + LLMEvent.textDelta({ + id: `text-${event.contentBlockDelta.contentBlockIndex}`, + text: event.contentBlockDelta.delta.text, + }), + ], + ] as const } if (event.contentBlockDelta?.delta?.reasoningContent?.text) { return [ state, - [{ type: "reasoning-delta" as const, text: event.contentBlockDelta.delta.reasoningContent.text }], + [ + LLMEvent.reasoningDelta({ + id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`, + text: event.contentBlockDelta.delta.reasoningContent.text, + }), + ], ] as const } @@ -449,16 +494,13 @@ const step = (state: ParserState, event: BedrockEvent) => event.modelStreamErrorException?.message ?? event.serviceUnavailableException?.message ?? "Bedrock Converse stream error" - return [state, [{ type: "provider-error" as const, message, retryable: true }]] as const + return [state, [LLMEvent.providerError({ message, retryable: true })]] as const } if (event.validationException || event.throttlingException) { const message = event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error" - return [ - state, - [{ type: "provider-error" as const, message, retryable: event.throttlingException !== undefined }], - ] as const + return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const } return [state, []] as const @@ -468,7 +510,7 @@ const framing = BedrockEventStream.framing(ADAPTER) const onHalt = (state: ParserState): ReadonlyArray => state.pendingFinish - ? [{ type: "request-finish", reason: state.pendingFinish.reason, usage: state.pendingFinish.usage }] + ? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })] : [] // ============================================================================= diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 0d2bdc8e1497..140da521a5a3 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -5,9 +5,9 @@ import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type FinishReason, - type LLMEvent, type LLMRequest, type MediaPart, type TextPart, @@ -311,7 +311,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean const finish = (state: ParserState): ReadonlyArray => state.finishReason || state.usage - ? [{ type: "request-finish", reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage }] + ? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })] : [] const step = (state: ParserState, event: GeminiEvent) => { @@ -332,14 +332,18 @@ const step = (state: ParserState, event: GeminiEvent) => { for (const part of candidate.content.parts) { if ("text" in part && part.text.length > 0) { - events.push({ type: part.thought ? "reasoning-delta" : "text-delta", text: part.text }) + events.push( + part.thought + ? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text }) + : LLMEvent.textDelta({ id: "text-0", text: part.text }), + ) continue } if ("functionCall" in part) { const input = part.functionCall.args const id = `tool_${nextToolCallId++}` - events.push({ type: "tool-call", id, name: part.functionCall.name, input }) + events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input })) hasToolCalls = true } } diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 974e22950d45..5d42c0a4e92b 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -6,9 +6,9 @@ import { Framing } from "../route/framing" import { HttpTransport } from "../route/transport" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type FinishReason, - type LLMEvent, type LLMRequest, type TextPart, type ToolCallPart, @@ -312,7 +312,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) => const toolDeltas = delta?.tool_calls ?? [] let tools = state.tools - if (delta?.content) events.push({ type: "text-delta", text: delta.content }) + if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content })) for (const tool of toolDeltas) { const result = ToolStream.appendOrStart( @@ -348,10 +348,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) => const finishEvents = (state: ParserState): ReadonlyArray => { const hasToolCalls = state.toolCallEvents.length > 0 const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason - return [ - ...state.toolCallEvents, - ...(reason ? ([{ type: "request-finish", reason, usage: state.usage }] satisfies ReadonlyArray) : []), - ] + return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])] } // ============================================================================= diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 780ed31bfcfa..14dc32130c96 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -6,9 +6,9 @@ import { Framing } from "../route/framing" import { HttpTransport, WebSocketTransport } from "../route/transport" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type FinishReason, - type LLMEvent, type LLMRequest, type ProviderMetadata, type TextPart, @@ -348,22 +348,20 @@ const hostedToolEvents = ( const tool = HOSTED_TOOLS[item.type] const providerMetadata = openaiMetadata({ itemId: item.id }) return [ - { - type: "tool-call", + LLMEvent.toolCall({ id: item.id, name: tool.name, input: tool.input(item), providerExecuted: true, providerMetadata, - }, - { - type: "tool-result", + }), + LLMEvent.toolResult({ id: item.id, name: tool.name, result: hostedToolResult(item), providerExecuted: true, providerMetadata, - }, + }), ] } @@ -379,17 +377,7 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { if (!event.delta) return [state, NO_EVENTS] - return [ - state, - [ - { - type: "text-delta", - id: event.item_id, - text: event.delta, - ...(event.item_id ? { providerMetadata: openaiMetadata({ itemId: event.item_id }) } : {}), - }, - ], - ] + return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]] } const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { @@ -458,30 +446,28 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, [ - { - type: "request-finish", + LLMEvent.requestFinish({ reason: mapFinishReason(event, state.hasFunctionCall), usage: mapUsage(event.response?.usage), - ...(event.response?.id || event.response?.service_tier - ? { - providerMetadata: openaiMetadata({ + providerMetadata: + event.response?.id || event.response?.service_tier + ? openaiMetadata({ responseId: event.response.id, serviceTier: event.response.service_tier, - }), - } - : {}), - }, + }) + : undefined, + }), ], ] const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, - [{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses response failed" }], + [LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })], ] const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, - [{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses stream error" }], + [LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })], ] const step = (state: ParserState, event: OpenAIResponsesEvent) => { diff --git a/packages/llm/src/protocols/utils/bedrock-cache.ts b/packages/llm/src/protocols/utils/bedrock-cache.ts index ca6e52cd118e..fab4d07b5c4a 100644 --- a/packages/llm/src/protocols/utils/bedrock-cache.ts +++ b/packages/llm/src/protocols/utils/bedrock-cache.ts @@ -1,20 +1,37 @@ import { Schema } from "effect" import type { CacheHint } from "../../schema" +import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache" // Bedrock cache markers are positional: emit a `cachePoint` block immediately -// after the content the caller wants treated as a cacheable prefix. +// after the content the caller wants treated as a cacheable prefix. Bedrock +// accepts optional `ttl: "5m" | "1h"` on cachePoint, mirroring Anthropic. export const CachePointBlock = Schema.Struct({ - cachePoint: Schema.Struct({ type: Schema.tag("default") }), + cachePoint: Schema.Struct({ + type: Schema.tag("default"), + ttl: Schema.optional(Schema.Literals(["5m", "1h"])), + }), }) export type CachePointBlock = Schema.Schema.Type -// Bedrock recently added optional `ttl: "5m" | "1h"` on cachePoint. Map -// `CacheHint.ttlSeconds` here once a recorded cassette validates the wire shape. -const DEFAULT: CachePointBlock = { cachePoint: { type: "default" } } +// Bedrock-Claude enforces the same 4-breakpoint cap as the Anthropic Messages +// API. Callers pass a shared counter through every `block()` call site so the +// budget is respected across `system`, `messages`, and `tools`. +export const BEDROCK_BREAKPOINT_CAP = 4 -export const block = (cache: CacheHint | undefined): CachePointBlock | undefined => { +export type { Breakpoints } from "./cache" +export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP) + +const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } } +const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } } + +export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => { if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined - return DEFAULT + if (breakpoints.remaining <= 0) { + breakpoints.dropped += 1 + return undefined + } + breakpoints.remaining -= 1 + return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M } export * as BedrockCache from "./bedrock-cache" diff --git a/packages/llm/src/protocols/utils/cache.ts b/packages/llm/src/protocols/utils/cache.ts new file mode 100644 index 000000000000..dd3e213e0eec --- /dev/null +++ b/packages/llm/src/protocols/utils/cache.ts @@ -0,0 +1,16 @@ +// Shared helpers for provider cache-marker lowering. Anthropic and Bedrock +// both enforce a 4-breakpoint cap per request and accept the same `5m`/`1h` +// TTL buckets, so the counter and TTL mapping live here. + +export interface Breakpoints { + remaining: number + dropped: number +} + +export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 }) + +// Returns `"1h"` for any `ttlSeconds >= 3600`, otherwise `undefined` (the +// provider default 5m). Anthropic & Bedrock both treat anything shorter than +// an hour as 5m. +export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined => + ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined diff --git a/packages/llm/src/protocols/utils/tool-stream.ts b/packages/llm/src/protocols/utils/tool-stream.ts index e6ac5fefd0bf..aa9c70f017b3 100644 --- a/packages/llm/src/protocols/utils/tool-stream.ts +++ b/packages/llm/src/protocols/utils/tool-stream.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { LLMError, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" +import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" import { eventError, parseToolInput, type ToolAccumulator } from "../shared" type StreamKey = string | number @@ -49,34 +49,24 @@ const withoutTool = (tools: State, key: K): State => return next } -const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({ - type: "tool-input-delta", - id: tool.id, - name: tool.name, - text, - ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), -}) +const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => + LLMEvent.toolInputDelta({ + id: tool.id, + name: tool.name, + text, + }) const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe( Effect.map( (input): ToolCall => - tool.providerExecuted - ? { - type: "tool-call", - id: tool.id, - name: tool.name, - input, - providerExecuted: true, - ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), - } - : { - type: "tool-call", - id: tool.id, - name: tool.name, - input, - ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), - }, + LLMEvent.toolCall({ + id: tool.id, + name: tool.name, + input, + providerExecuted: tool.providerExecuted ? true : undefined, + providerMetadata: tool.providerMetadata, + }), ), ) diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 2fa69370f40b..d0befe246eba 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { FinishReason, ProtocolID, ProviderMetadata, RouteID } from "./ids" +import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids" import { ModelRef } from "./options" import { ToolResultValue } from "./messages" @@ -14,60 +14,87 @@ export class Usage extends Schema.Class("LLM.Usage")({ }) {} export const RequestStart = Schema.Struct({ - type: Schema.Literal("request-start"), - id: Schema.String, + type: Schema.tag("request-start"), + id: ResponseID, model: ModelRef, }).annotate({ identifier: "LLM.Event.RequestStart" }) export type RequestStart = Schema.Schema.Type export const StepStart = Schema.Struct({ - type: Schema.Literal("step-start"), + type: Schema.tag("step-start"), index: Schema.Number, }).annotate({ identifier: "LLM.Event.StepStart" }) export type StepStart = Schema.Schema.Type export const TextStart = Schema.Struct({ - type: Schema.Literal("text-start"), - id: Schema.String, + type: Schema.tag("text-start"), + id: ContentBlockID, providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.TextStart" }) export type TextStart = Schema.Schema.Type export const TextDelta = Schema.Struct({ - type: Schema.Literal("text-delta"), - id: Schema.optional(Schema.String), + type: Schema.tag("text-delta"), + id: ContentBlockID, text: Schema.String, - providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.TextDelta" }) export type TextDelta = Schema.Schema.Type export const TextEnd = Schema.Struct({ - type: Schema.Literal("text-end"), - id: Schema.String, + type: Schema.tag("text-end"), + id: ContentBlockID, providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.TextEnd" }) export type TextEnd = Schema.Schema.Type +export const ReasoningStart = Schema.Struct({ + type: Schema.tag("reasoning-start"), + id: ContentBlockID, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ReasoningStart" }) +export type ReasoningStart = Schema.Schema.Type + export const ReasoningDelta = Schema.Struct({ - type: Schema.Literal("reasoning-delta"), - id: Schema.optional(Schema.String), + type: Schema.tag("reasoning-delta"), + id: ContentBlockID, text: Schema.String, - providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.ReasoningDelta" }) export type ReasoningDelta = Schema.Schema.Type +export const ReasoningEnd = Schema.Struct({ + type: Schema.tag("reasoning-end"), + id: ContentBlockID, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ReasoningEnd" }) +export type ReasoningEnd = Schema.Schema.Type + +export const ToolInputStart = Schema.Struct({ + type: Schema.tag("tool-input-start"), + id: ToolCallID, + name: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolInputStart" }) +export type ToolInputStart = Schema.Schema.Type + export const ToolInputDelta = Schema.Struct({ - type: Schema.Literal("tool-input-delta"), - id: Schema.String, + type: Schema.tag("tool-input-delta"), + id: ToolCallID, name: Schema.String, text: Schema.String, - providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.ToolInputDelta" }) export type ToolInputDelta = Schema.Schema.Type +export const ToolInputEnd = Schema.Struct({ + type: Schema.tag("tool-input-end"), + id: ToolCallID, + name: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolInputEnd" }) +export type ToolInputEnd = Schema.Schema.Type + export const ToolCall = Schema.Struct({ - type: Schema.Literal("tool-call"), - id: Schema.String, + type: Schema.tag("tool-call"), + id: ToolCallID, name: Schema.String, input: Schema.Unknown, providerExecuted: Schema.optional(Schema.Boolean), @@ -76,8 +103,8 @@ export const ToolCall = Schema.Struct({ export type ToolCall = Schema.Schema.Type export const ToolResult = Schema.Struct({ - type: Schema.Literal("tool-result"), - id: Schema.String, + type: Schema.tag("tool-result"), + id: ToolCallID, name: Schema.String, result: ToolResultValue, providerExecuted: Schema.optional(Schema.Boolean), @@ -86,8 +113,8 @@ export const ToolResult = Schema.Struct({ export type ToolResult = Schema.Schema.Type export const ToolError = Schema.Struct({ - type: Schema.Literal("tool-error"), - id: Schema.String, + type: Schema.tag("tool-error"), + id: ToolCallID, name: Schema.String, message: Schema.String, providerMetadata: Schema.optional(ProviderMetadata), @@ -95,7 +122,7 @@ export const ToolError = Schema.Struct({ export type ToolError = Schema.Schema.Type export const StepFinish = Schema.Struct({ - type: Schema.Literal("step-finish"), + type: Schema.tag("step-finish"), index: Schema.Number, reason: FinishReason, usage: Schema.optional(Usage), @@ -104,7 +131,7 @@ export const StepFinish = Schema.Struct({ export type StepFinish = Schema.Schema.Type export const RequestFinish = Schema.Struct({ - type: Schema.Literal("request-finish"), + type: Schema.tag("request-finish"), reason: FinishReason, usage: Schema.optional(Usage), providerMetadata: Schema.optional(ProviderMetadata), @@ -112,7 +139,7 @@ export const RequestFinish = Schema.Struct({ export type RequestFinish = Schema.Schema.Type export const ProviderErrorEvent = Schema.Struct({ - type: Schema.Literal("provider-error"), + type: Schema.tag("provider-error"), message: Schema.String, retryable: Schema.optional(Schema.Boolean), providerMetadata: Schema.optional(ProviderMetadata), @@ -125,8 +152,12 @@ const llmEventTagged = Schema.Union([ TextStart, TextDelta, TextEnd, + ReasoningStart, ReasoningDelta, + ReasoningEnd, + ToolInputStart, ToolInputDelta, + ToolInputEnd, ToolCall, ToolResult, ToolError, @@ -135,20 +166,52 @@ const llmEventTagged = Schema.Union([ ProviderErrorEvent, ]).pipe(Schema.toTaggedUnion("type")) +type WithID = Omit & { readonly id: ID | string } + +const responseID = (value: ResponseID | string) => ResponseID.make(value) +const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value) +const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value) + /** * camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`). * Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of * `events.filter(LLMEvent.guards["tool-call"])`. */ export const LLMEvent = Object.assign(llmEventTagged, { + requestStart: (input: WithID) => RequestStart.make({ ...input, id: responseID(input.id) }), + stepStart: StepStart.make, + textStart: (input: WithID) => TextStart.make({ ...input, id: contentBlockID(input.id) }), + textDelta: (input: WithID) => TextDelta.make({ ...input, id: contentBlockID(input.id) }), + textEnd: (input: WithID) => TextEnd.make({ ...input, id: contentBlockID(input.id) }), + reasoningStart: (input: WithID) => + ReasoningStart.make({ ...input, id: contentBlockID(input.id) }), + reasoningDelta: (input: WithID) => + ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }), + reasoningEnd: (input: WithID) => + ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }), + toolInputStart: (input: WithID) => + ToolInputStart.make({ ...input, id: toolCallID(input.id) }), + toolInputDelta: (input: WithID) => + ToolInputDelta.make({ ...input, id: toolCallID(input.id) }), + toolInputEnd: (input: WithID) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }), + toolCall: (input: WithID) => ToolCall.make({ ...input, id: toolCallID(input.id) }), + toolResult: (input: WithID) => ToolResult.make({ ...input, id: toolCallID(input.id) }), + toolError: (input: WithID) => ToolError.make({ ...input, id: toolCallID(input.id) }), + stepFinish: StepFinish.make, + requestFinish: RequestFinish.make, + providerError: ProviderErrorEvent.make, is: { requestStart: llmEventTagged.guards["request-start"], stepStart: llmEventTagged.guards["step-start"], textStart: llmEventTagged.guards["text-start"], textDelta: llmEventTagged.guards["text-delta"], textEnd: llmEventTagged.guards["text-end"], + reasoningStart: llmEventTagged.guards["reasoning-start"], reasoningDelta: llmEventTagged.guards["reasoning-delta"], + reasoningEnd: llmEventTagged.guards["reasoning-end"], + toolInputStart: llmEventTagged.guards["tool-input-start"], toolInputDelta: llmEventTagged.guards["tool-input-delta"], + toolInputEnd: llmEventTagged.guards["tool-input-end"], toolCall: llmEventTagged.guards["tool-call"], toolResult: llmEventTagged.guards["tool-result"], toolError: llmEventTagged.guards["tool-error"], diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts index 926184277023..ada133f0db58 100644 --- a/packages/llm/src/schema/ids.ts +++ b/packages/llm/src/schema/ids.ts @@ -14,6 +14,15 @@ export type ModelID = typeof ModelID.Type export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID")) export type ProviderID = typeof ProviderID.Type +export const ResponseID = Schema.String +export type ResponseID = Schema.Schema.Type + +export const ContentBlockID = Schema.String +export type ContentBlockID = Schema.Schema.Type + +export const ToolCallID = Schema.String +export type ToolCallID = Schema.Schema.Type + export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const export const ReasoningEffort = Schema.Literals(ReasoningEfforts) export type ReasoningEffort = Schema.Schema.Type diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts index 3daf00bbc0d0..cc6b89a2c73d 100644 --- a/packages/llm/src/schema/messages.ts +++ b/packages/llm/src/schema/messages.ts @@ -79,6 +79,7 @@ export const ToolResultPart = Object.assign( name: Schema.String, result: ToolResultValue, providerExecuted: Schema.optional(Schema.Boolean), + cache: Schema.optional(CacheHint), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Content.ToolResult" }), @@ -94,6 +95,7 @@ export const ToolResultPart = Object.assign( name: input.name, result: ToolResultValue.make(input.result, input.resultType), providerExecuted: input.providerExecuted, + cache: input.cache, metadata: input.metadata, providerMetadata: input.providerMetadata, }), @@ -151,6 +153,7 @@ export class ToolDefinition extends Schema.Class("LLM.ToolDefini name: Schema.String, description: Schema.String, inputSchema: JsonSchema, + cache: Schema.optional(CacheHint), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts index 20e27379bd97..c6e716d45ee3 100644 --- a/packages/llm/src/tool-runtime.ts +++ b/packages/llm/src/tool-runtime.ts @@ -4,7 +4,7 @@ import { type ContentPart, type FinishReason, type LLMError, - type LLMEvent, + LLMEvent, LLMRequest, Message, type ProviderMetadata, @@ -115,11 +115,19 @@ interface StepState { const accumulate = (state: StepState, event: LLMEvent) => { if (event.type === "text-delta") { - appendStreamingText(state, "text", event.text, event.providerMetadata) + appendStreamingText(state, "text", event.text, undefined) return } if (event.type === "reasoning-delta") { - appendStreamingText(state, "reasoning", event.text, event.providerMetadata) + appendStreamingText(state, "reasoning", event.text, undefined) + return + } + if (event.type === "reasoning-end") { + appendStreamingText(state, "reasoning", "", event.providerMetadata) + return + } + if (event.type === "text-end") { + appendStreamingText(state, "text", "", event.providerMetadata) return } if (event.type === "tool-call") { @@ -219,10 +227,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect => result.type === "error" ? [ - { type: "tool-error", id: call.id, name: call.name, message: String(result.value) }, - { type: "tool-result", id: call.id, name: call.name, result }, + LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }), + LLMEvent.toolResult({ id: call.id, name: call.name, result }), ] - : [{ type: "tool-result", id: call.id, name: call.name, result }] + : [LLMEvent.toolResult({ id: call.id, name: call.name, result })] const followUpRequest = ( request: LLMRequest, diff --git a/packages/llm/sst-env.d.ts b/packages/llm/sst-env.d.ts new file mode 100644 index 000000000000..64441936d7a0 --- /dev/null +++ b/packages/llm/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index 191b8529c06a..5ac8b9d818fd 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -50,7 +50,9 @@ const request = LLM.request({ }) const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => - event.type === "finish" ? { type: "request-finish", reason: event.reason } : { type: "text-delta", text: event.text } + event.type === "finish" + ? { type: "request-finish", reason: event.reason } + : { type: "text-delta", id: "text-0", text: event.text } const fakeProtocol = Protocol.make({ id: "fake", diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index 9380e554bf34..e9ef58afa836 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -126,7 +126,7 @@ describe("llm constructors", () => { expect( LLMResponse.text({ events: [ - { type: "text-delta", text: "hi" }, + { type: "text-delta", id: "text-0", text: "hi" }, { type: "request-finish", reason: "stop" }, ], }), diff --git a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts new file mode 100644 index 000000000000..b048d53ba0c2 --- /dev/null +++ b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts @@ -0,0 +1,48 @@ +import { Redactor } from "@opencode-ai/http-recorder" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as AnthropicMessages from "../../src/protocols/anthropic-messages" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = AnthropicMessages.model({ + id: "claude-haiku-4-5-20251001", + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}) + +// Two identical generations in a row. The first call writes the prefix into +// Anthropic's cache; the second should report a cache read against the same +// prefix. Cassette captures both interactions in order. +const cacheRequest = LLM.request({ + id: "recorded_anthropic_cache", + model, + system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "anthropic-messages-cache", + provider: "anthropic", + protocol: "anthropic-messages", + requires: ["ANTHROPIC_API_KEY"], + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, +}) + +describe("Anthropic Messages cache recorded", () => { + recorded.effect.with("writes then reads cache_control on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + // The first call may write the cache (cacheWriteInputTokens > 0) or it + // may be a fresh miss (both fields 0) depending on whether the prefix is + // already warm on Anthropic's side. The assertion that matters is that + // the SECOND call reports a non-zero cache read. + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/anthropic-messages.recorded.test.ts b/packages/llm/test/provider/anthropic-messages.recorded.test.ts index a8d87c46ffa5..aa5b258d3d59 100644 --- a/packages/llm/test/provider/anthropic-messages.recorded.test.ts +++ b/packages/llm/test/provider/anthropic-messages.recorded.test.ts @@ -1,3 +1,4 @@ +import { Redactor } from "@opencode-ai/http-recorder" import { describe, expect } from "bun:test" import { Effect } from "effect" import { LLM, LLMError } from "../../src" @@ -30,7 +31,7 @@ const recorded = recordedTests({ provider: "anthropic", protocol: "anthropic-messages", requires: ["ANTHROPIC_API_KEY"], - options: { requestHeaders: ["content-type", "anthropic-version"] }, + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, }) describe("Anthropic Messages sad-path recorded", () => { diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 263828a0ade3..2f2b2a3e8618 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -115,7 +115,7 @@ describe("Anthropic Messages route", () => { cacheReadInputTokens: 1, totalTokens: 7, }) - expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toMatchObject({ + expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ providerMetadata: { anthropic: { signature: "sig_1" } }, }) expect(response.events.at(-1)).toMatchObject({ @@ -374,4 +374,134 @@ describe("Anthropic Messages route", () => { expect(error.message).toContain("Anthropic Messages user messages only support text content for now") }), ) + + it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) }, + prompt: "hi", + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "1h" } }], + }) + }), + ) + + it.effect("emits cache_control on tool definitions and tool-result blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [ + { + name: "lookup", + description: "lookup tool", + inputSchema: { type: "object", properties: {} }, + cache: new CacheHint({ type: "ephemeral" }), + }, + ], + messages: [ + LLM.user("What's the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), + LLM.toolMessage({ + id: "call_1", + name: "lookup", + result: { temp: 72 }, + cache: new CacheHint({ type: "ephemeral" }), + }), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }], + messages: [ + { role: "user", content: [{ type: "text", text: "What's the weather?" }] }, + { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup" }] }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "call_1", cache_control: { type: "ephemeral" } }], + }, + ], + }) + }), + ) + + it.effect("drops cache_control breakpoints past the 4-per-request cap", () => + Effect.gen(function* () { + const hint = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [ + { type: "text", text: "a", cache: hint }, + { type: "text", text: "b", cache: hint }, + { type: "text", text: "c", cache: hint }, + { type: "text", text: "d", cache: hint }, + { type: "text", text: "e", cache: hint }, + { type: "text", text: "f", cache: hint }, + ], + prompt: "hi", + }), + ) + + const system = (prepared.body as { system: Array<{ cache_control?: unknown }> }).system + const marked = system.filter((part) => part.cache_control !== undefined) + expect(marked).toHaveLength(4) + expect(system[4]?.cache_control).toBeUndefined() + expect(system[5]?.cache_control).toBeUndefined() + }), + ) + + it.effect("spends breakpoint budget on tools before system before messages", () => + Effect.gen(function* () { + const hint = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [ + { + name: "t1", + description: "t1", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t2", + description: "t2", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t3", + description: "t3", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t4", + description: "t4", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + ], + system: [{ type: "text", text: "system-tail", cache: hint }], + messages: [LLM.user([{ type: "text", text: "message-tail", cache: hint }])], + }), + ) + + const body = prepared.body as { + tools: Array<{ cache_control?: unknown }> + system: Array<{ cache_control?: unknown }> + messages: Array<{ content: Array<{ cache_control?: unknown }> }> + } + expect(body.tools.every((t) => t.cache_control !== undefined)).toBe(true) + expect(body.system[0]?.cache_control).toBeUndefined() + expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined() + }), + ) }) diff --git a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts new file mode 100644 index 000000000000..23dd697b9a43 --- /dev/null +++ b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts @@ -0,0 +1,50 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as BedrockConverse from "../../src/protocols/bedrock-converse" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1" + +// Use a Claude model on Bedrock — Nova has automatic prefix caching that +// doesn't reliably surface `cacheRead`/`cacheWrite` in usage, so the second +// call wouldn't deterministically prove cache mapping works. Override with +// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere. +const model = BedrockConverse.model({ + id: process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0", + credentials: { + region: RECORDING_REGION, + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture", + sessionToken: process.env.AWS_SESSION_TOKEN, + }, +}) + +const cacheRequest = LLM.request({ + id: "recorded_bedrock_cache", + model, + system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "bedrock-converse-cache", + provider: "amazon-bedrock", + protocol: "bedrock-converse", + requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], +}) + +describe("Bedrock Converse cache recorded", () => { + recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 28be714bdf3b..afadd89ac744 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -440,6 +440,77 @@ describe("Bedrock Converse route", () => { expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar") }), ) + + it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [{ type: "text", text: "system", cache }], + prompt: "hi", + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ text: "system" }, { cachePoint: { type: "default", ttl: "1h" } }], + }) + }), + ) + + it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }], + messages: [ + LLM.user("What's the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + toolConfig: { + tools: [{ toolSpec: { name: "lookup" } }, { cachePoint: { type: "default" } }], + }, + messages: [ + { role: "user", content: [{ text: "What's the weather?" }] }, + { role: "assistant", content: [{ toolUse: { toolUseId: "call_1" } }] }, + { + role: "user", + content: [{ toolResult: { toolUseId: "call_1" } }, { cachePoint: { type: "default" } }], + }, + ], + }) + }), + ) + + it.effect("drops cachePoint markers past the 4-per-request cap", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [ + { type: "text", text: "a", cache }, + { type: "text", text: "b", cache }, + { type: "text", text: "c", cache }, + { type: "text", text: "d", cache }, + { type: "text", text: "e", cache }, + { type: "text", text: "f", cache }, + ], + prompt: "hi", + }), + ) + + const system = (prepared.body as { system: Array<{ cachePoint?: unknown }> }).system + expect(system.filter((part) => "cachePoint" in part)).toHaveLength(4) + }), + ) }) // Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=... diff --git a/packages/llm/test/provider/gemini-cache.recorded.test.ts b/packages/llm/test/provider/gemini-cache.recorded.test.ts new file mode 100644 index 000000000000..145728fdc69d --- /dev/null +++ b/packages/llm/test/provider/gemini-cache.recorded.test.ts @@ -0,0 +1,47 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as Gemini from "../../src/protocols/gemini" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = Gemini.model({ + id: "gemini-2.5-flash", + apiKey: process.env.GEMINI_API_KEY ?? "fixture", +}) + +// Gemini does implicit prefix caching on 2.5+ models above ~1024 tokens. The +// `CacheHint` is currently a no-op for Gemini (the explicit `CachedContent` +// API is out-of-band and intentionally not wired up). This test exists to +// pin the usage-parsing path: `cachedContentTokenCount` should surface as +// `cacheReadInputTokens` on the second identical call. +const cacheRequest = LLM.request({ + id: "recorded_gemini_cache", + model, + system: LARGE_CACHEABLE_SYSTEM, + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "gemini-cache", + provider: "google", + protocol: "gemini", + requires: ["GEMINI_API_KEY"], +}) + +describe("Gemini cache recorded", () => { + recorded.effect.with("reports cachedContentTokenCount on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + // Implicit caching is best-effort on Gemini's side; we assert the field + // is at least populated and non-negative. When re-recording, verify the + // cassette shows > 0 in the second response's usage. + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + }), + ) +}) diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index a80ab740c3f7..9de4e0dc257f 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -204,9 +204,9 @@ describe("Gemini route", () => { totalTokens: 7, }) expect(response.events).toEqual([ - { type: "reasoning-delta", text: "thinking" }, - { type: "text-delta", text: "Hello" }, - { type: "text-delta", text: "!" }, + { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "text-delta", id: "text-0", text: "Hello" }, + { type: "text-delta", id: "text-0", text: "!" }, { type: "request-finish", reason: "stop", diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index 0e1151b7af06..3fa27c706e96 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -1,3 +1,4 @@ +import { Redactor } from "@opencode-ai/http-recorder" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import * as Gemini from "../../src/protocols/gemini" import * as OpenAIChat from "../../src/protocols/openai-chat" @@ -66,7 +67,7 @@ const redactCloudflareURL = (url: string) => .replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/") const cloudflareOptions = { - redact: { url: redactCloudflareURL }, + redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }), } describeRecordedGoldenScenarios([ @@ -102,7 +103,7 @@ describeRecordedGoldenScenarios([ prefix: "anthropic-messages", model: anthropicHaiku, requires: ["ANTHROPIC_API_KEY"], - options: { requestHeaders: ["content-type", "anthropic-version"] }, + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, scenarios: ["text", "tool-call"], }, { @@ -111,7 +112,7 @@ describeRecordedGoldenScenarios([ model: anthropicOpus, requires: ["ANTHROPIC_API_KEY"], tags: ["flagship"], - options: { requestHeaders: ["content-type", "anthropic-version"] }, + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, scenarios: [{ id: "tool-loop", temperature: false }], }, { diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 22f032b47ac7..f72b3c6f43af 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -228,8 +228,8 @@ describe("OpenAI Chat route", () => { expect(response.text).toBe("Hello!") expect(response.events).toEqual([ - { type: "text-delta", text: "Hello" }, - { type: "text-delta", text: "!" }, + { type: "text-delta", id: "text-0", text: "Hello" }, + { type: "text-delta", id: "text-0", text: "!" }, { type: "request-finish", reason: "stop", diff --git a/packages/llm/test/provider/openai-responses-cache.recorded.test.ts b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts new file mode 100644 index 000000000000..0ac3dfe2b978 --- /dev/null +++ b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts @@ -0,0 +1,44 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as OpenAIResponses from "../../src/protocols/openai-responses" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = OpenAIResponses.model({ + id: "gpt-4.1-mini", + apiKey: process.env.OPENAI_API_KEY ?? "fixture", +}) + +// OpenAI caches prefixes automatically once they cross the 1024-token threshold; +// `CacheHint` is a no-op for the wire body. The stable signal is the +// `prompt_cache_key` routing hint, which keeps repeated calls on the same shard +// so cache hits are observable. +const cacheRequest = LLM.request({ + id: "recorded_openai_responses_cache", + model, + system: LARGE_CACHEABLE_SYSTEM, + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, + providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } }, +}) + +const recorded = recordedTests({ + prefix: "openai-responses-cache", + provider: "openai", + protocol: "openai-responses", + requires: ["OPENAI_API_KEY"], +}) + +describe("OpenAI Responses cache recorded", () => { + recorded.effect.with("reports cached_tokens on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 1a7d21f28205..0df7ec0cee4d 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -340,8 +340,8 @@ describe("OpenAI Responses route", () => { expect(response.text).toBe("Hello!") expect(response.events).toEqual([ - { type: "text-delta", id: "msg_1", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } }, - { type: "text-delta", id: "msg_1", text: "!", providerMetadata: { openai: { itemId: "msg_1" } } }, + { type: "text-delta", id: "msg_1", text: "Hello" }, + { type: "text-delta", id: "msg_1", text: "!" }, { type: "request-finish", reason: "stop", @@ -398,14 +398,12 @@ describe("OpenAI Responses route", () => { id: "call_1", name: "lookup", text: '{"query"', - providerMetadata: { openai: { itemId: "item_1" } }, }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', - providerMetadata: { openai: { itemId: "item_1" } }, }, { type: "tool-call", diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 3fb3e0b9a950..8a02bc3a0a19 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -6,6 +6,18 @@ import { tool } from "../src/tool" export const weatherToolName = "get_weather" +// A deterministic system prompt long enough to clear every supported provider's +// minimum cacheable-prefix threshold (Anthropic Haiku 3.5: 2048 tokens; Anthropic +// Opus/Haiku 4.5: 4096 tokens; OpenAI/Gemini/Bedrock: lower). Built by repeating +// a fixed sentence — the cassette replays bit-for-bit, so the exact text matters +// only when re-recording with `RECORD=true`. +export const LARGE_CACHEABLE_SYSTEM = (() => { + const sentence = "You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. " + // ~100 chars per sentence × 250 repeats ≈ 25,000 chars ≈ 5k+ tokens, safely + // above every provider's threshold. + return sentence.repeat(250) +})() + export const weatherTool = LLM.toolDefinition({ name: weatherToolName, description: "Get current weather for a city.", diff --git a/packages/llm/test/recorded-test.ts b/packages/llm/test/recorded-test.ts index 6514f13dad2f..62e51337d933 100644 --- a/packages/llm/test/recorded-test.ts +++ b/packages/llm/test/recorded-test.ts @@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) => ...metadata, } const mode = recorderOptions?.mode ?? (recording ? "record" : "replay") - const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe( + const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe( Layer.provide(NodeFileSystem.layer), ) const requestExecutor = RequestExecutor.layer.pipe( diff --git a/packages/llm/test/recorded-websocket.ts b/packages/llm/test/recorded-websocket.ts index eeea9f1b780a..b7ad380dad37 100644 --- a/packages/llm/test/recorded-websocket.ts +++ b/packages/llm/test/recorded-websocket.ts @@ -1,14 +1,13 @@ -import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder" +import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder" import { Effect, Layer } from "effect" import { WebSocketExecutor } from "../src/route" import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" const liveWebSocket = WebSocketExecutor.open -type Mode = "record" | "replay" | "passthrough" export const webSocketCassetteLayer = ( cassette: string, - input: { readonly metadata?: Record; readonly mode: Mode }, + input: { readonly metadata?: Record; readonly mode: RecordReplayMode }, ): Layer.Layer => Layer.effect( WebSocketExecutor.Service, diff --git a/packages/opencode/migration/20260511000411_data_migration_state/migration.sql b/packages/opencode/migration/20260511000411_data_migration_state/migration.sql new file mode 100644 index 000000000000..ba36a7f078dc --- /dev/null +++ b/packages/opencode/migration/20260511000411_data_migration_state/migration.sql @@ -0,0 +1,4 @@ +CREATE TABLE `data_migration` ( + `name` text PRIMARY KEY, + `time_completed` integer NOT NULL +); diff --git a/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json b/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json new file mode 100644 index 000000000000..e84aa1a6a10f --- /dev/null +++ b/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json @@ -0,0 +1,1490 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "fdfcccee-fb3a-481f-b801-b9835fa30d5d", + "prevIds": ["630a93f2-c6c6-4191-a351-868d8f3a05d4"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["name"], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 9e7a3f3bc209..b881e5e75a3f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -4,23 +4,6 @@ "name": "@kilocode/cli", "type": "module", "license": "MIT", - "keywords": [ - "cli", - "tui", - "terminal", - "ai", - "agent", - "assistant", - "coding-agent", - "kilo-code", - "kilo", - "opencode", - "ink", - "react", - "copilot", - "autocomplete", - "developer-tools" - ], "private": false, "scripts": { "typecheck": "tsgo --noEmit", @@ -155,6 +138,7 @@ "@pierre/diffs": "catalog:", "@secretlint/core": "10.2.2", "@secretlint/secretlint-rule-preset-recommend": "10.2.2", + "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", @@ -217,5 +201,22 @@ "overrides": { "drizzle-orm": "catalog:" }, - "peerDependencies": {} + "peerDependencies": {}, + "keywords": [ + "cli", + "tui", + "terminal", + "ai", + "agent", + "assistant", + "coding-agent", + "kilo-code", + "kilo", + "opencode", + "ink", + "react", + "copilot", + "autocomplete", + "developer-tools" + ] } diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md index 55e4c7268d4c..255c09644f82 100644 --- a/packages/opencode/specs/openapi-translation-cleanup.md +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -105,13 +105,13 @@ Verification: Concrete first targets: -- `sessionID` -- `messageID` -- `partID` -- `permissionID` -- `ptyID` +- `[x]` `sessionID` +- `[x]` `messageID` +- `[x]` `partID` +- `[x]` `permissionID` +- `[x]` `ptyID` -Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params. +- `[x]` Remove ambiguous workspace `id` path overrides once the endpoint source schema emits the `wrk` pattern. Verification: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5bc2372e9f12..067273e90389 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -25,11 +25,9 @@ import { Effect, Context, Layer, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { zod } from "@opencode-ai/core/effect-zod" import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" +import { Reference } from "@/reference/reference" import * as KiloAgent from "@/kilocode/agent" // kilocode_change -type ReferenceEntry = NonNullable[string] -type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } - export const Info = Schema.Struct({ name: Schema.String, displayName: Schema.optional(Schema.String), // kilocode_change - human-readable name for org modes @@ -103,7 +101,7 @@ export const layer = Layer.effect( } satisfies Record const baseDefaults = Permission.fromConfig({ - // kilocode_change: renamed from defaults + // kilocode_change "*": "allow", doom_loop: "ask", external_directory: { @@ -290,9 +288,7 @@ export const layer = Layer.effect( // kilocode_change start - rename build→code, add debug/orchestrator/ask, patch plan/explore KiloAgent.patchAgents(agents, defaults, user, cfg, kilo, ctx.worktree, whitelistedDirs) - // kilocode_change end - // kilocode_change start - preprocess config to remap "build" key → "code" const agentConfigs = KiloAgent.preprocessConfig(cfg.agent ?? {}) for (const [key, value] of Object.entries(agentConfigs)) { // kilocode_change end @@ -325,69 +321,70 @@ export const layer = Layer.effect( KiloAgent.processConfigItem(item) // kilocode_change - populate displayName from options } - function referencePath(value: string) { - if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2)) - return path.isAbsolute(value) - ? value - : path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value) - } - - function resolveReference(reference: ReferenceEntry): ResolvedReference { - if (typeof reference === "string") { - if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) { - return { kind: "local", path: referencePath(reference) } - } - return { kind: "git", repository: reference } - } - if ("path" in reference) return { kind: "local", path: referencePath(reference.path) } - return { kind: "git", repository: reference.repository, branch: reference.branch } - } - - function referencePrompt(name: string, reference: ResolvedReference) { + function referencePrompt(reference: Reference.Resolved) { if (reference.kind === "local") { return [ - PROMPT_SCOUT, - `You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`, + `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, `Local directory: ${reference.path}`, - `When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, + `Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, + `Return exact absolute file paths for findings whenever possible.`, + ].join("\n\n") + } + + if (reference.kind === "invalid") { + return [ + `You are configured reference @${reference.name}, but this reference is not usable yet.`, + `Configured repository: ${reference.repository}`, + `Problem: ${reference.message}`, + `Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`, ].join("\n\n") } return [ - PROMPT_SCOUT, - `You are Scout reference @${name}. This reference points to a git repository.`, + `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, `Repository: ${reference.repository}`, ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []), - `When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`, + `Cached directory: ${reference.path}`, + `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") } + function referenceDescription(reference: Reference.Resolved) { + if (reference.kind === "local") return `Scout reference for local directory ${reference.path}` + if (reference.kind === "git") return `Scout reference for repository ${reference.repository}` + return `Invalid Scout reference for repository ${reference.repository}` + } + if (Flag.KILO_EXPERIMENTAL_SCOUT) { - for (const [name, reference] of Object.entries(cfg.reference ?? {})) { - if (agents[name]) continue - const resolved = resolveReference(reference) - const localPath = resolved.kind === "local" ? resolved.path : undefined - agents[name] = { - name, - description: - resolved.kind === "local" - ? `Scout reference for local directory ${resolved.path}` - : `Scout reference for repository ${resolved.repository}`, + const resolvedReferences = Reference.resolveAll({ + references: cfg.reference ?? {}, + directory: ctx.directory, + worktree: ctx.worktree, + }) + for (const resolved of resolvedReferences) { + if (agents[resolved.name]) continue + const localPath = resolved.kind === "invalid" ? undefined : resolved.path + agents[resolved.name] = { + name: resolved.name, + description: referenceDescription(resolved), permission: Permission.merge( agents.scout.permission, - Permission.fromConfig( - localPath + Permission.fromConfig({ + repo_clone: "deny", + ...(localPath ? { external_directory: { [localPath]: "allow", [path.join(localPath, "*")]: "allow", }, } - : {}, - ), + : {}), + }), ), - prompt: referencePrompt(name, resolved), - options: { reference }, + prompt: referencePrompt(resolved), + options: { reference: cfg.reference?.[resolved.name], resolved }, mode: "subagent", native: false, } @@ -429,8 +426,10 @@ export const layer = Layer.effect( const defaultAgent = Effect.fnUntraced(function* () { const c = yield* config.get() if (c.default_agent) { - const effective = KiloAgent.resolveKey(c.default_agent) // kilocode_change - treat "build" as "code" - const agent = agents[effective] // kilocode_change + // kilocode_change start + const effective = KiloAgent.resolveKey(c.default_agent) + const agent = agents[effective] + // kilocode_change end if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) diff --git a/packages/opencode/src/audio.d.ts b/packages/opencode/src/audio.d.ts index 54a86efa3044..c7c947450dcf 100644 --- a/packages/opencode/src/audio.d.ts +++ b/packages/opencode/src/audio.d.ts @@ -2,3 +2,8 @@ declare module "*.wav" { const file: string export default file } + +declare module "*.wasm" { + const file: string + export default file +} diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index f6db24794050..fb738cc0eddf 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,4 +1,4 @@ -import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" // kilocode_change import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import * as Clipboard from "@tui/util/clipboard" import * as Selection from "@tui/util/selection" @@ -52,8 +52,10 @@ import { DialogConfirm } from "./ui/dialog-confirm" import { ToastProvider, useToast } from "./ui/toast" import { ExitProvider, useExit } from "./context/exit" import { Session as SessionApi } from "@/session/session" +// kilocode_change start import { DialogSelect } from "./ui/dialog-select" import { Link } from "./ui/link" +// kilocode_change end import { TuiEvent } from "./event" import { KVProvider, useKV } from "./context/kv" import { Provider } from "@/provider/provider" @@ -68,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" @@ -97,7 +100,6 @@ const appBindingCommands = [ "theme.mode.lock", "help.show", "docs.open", - "app.exit", "app.debug", "app.console", "app.heap_snapshot", @@ -177,7 +179,6 @@ export function tui(input: { await TuiPluginRuntime.dispose() } - // kilocode_change - safety net: ensure mouse tracking is disabled regardless of exit path process.on("exit", resetTerminalState) // kilocode_change const renderer = await createCliRenderer(rendererConfig(input.config)) @@ -191,11 +192,9 @@ export function tui(input: { await render(() => { return ( ( )} - // kilocode_change end > @@ -337,8 +336,10 @@ function App(props: { onSnapshot?: () => Promise }) { kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary), ) - KiloApp.useSessionEffects({ route, sdk, sync }) // kilocode_change - KiloApp.useTuiConfigHotReload() // kilocode_change - hot reload TUI keybinds/theme/ui settings + // kilocode_change start + KiloApp.useSessionEffects({ route, sdk, sync }) + KiloApp.useTuiConfigHotReload() + // kilocode_change end // Update terminal window title based on current route and session createEffect(() => { @@ -347,24 +348,24 @@ function App(props: { onSnapshot?: () => Promise }) { const titleDefault = KiloApp.APP_TITLE // kilocode_change if (route.data.type === "home") { - renderer.setTerminalTitle(titleDefault) + renderer.setTerminalTitle(titleDefault) // kilocode_change return } if (route.data.type === "session") { const session = sync.session.get(route.data.sessionID) if (!session || SessionApi.isDefaultTitle(session.title)) { - renderer.setTerminalTitle(titleDefault) + renderer.setTerminalTitle(titleDefault) // kilocode_change return } const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title - renderer.setTerminalTitle(`${titleDefault} | ${title}`) + renderer.setTerminalTitle(`${titleDefault} | ${title}`) // kilocode_change return } if (route.data.type === "plugin") { - renderer.setTerminalTitle(`${titleDefault} | ${route.data.id}`) + renderer.setTerminalTitle(`${titleDefault} | ${route.data.id}`) // kilocode_change } // kilocode_change start @@ -663,19 +664,7 @@ function App(props: { onSnapshot?: () => Promise }) { }, category: "System", }, - { - name: "app.exit", - title: "Exit the app", - slashName: "exit", - slashAliases: ["quit", "q"], - enabled: () => { - const current = promptRef.current - if (!current?.focused) return true - return current.current.input === "" - }, - run: () => exit(), - category: "System", - }, + AppExit.command(exit), // kilocode_change { name: "app.debug", title: "Toggle debug panel", @@ -816,6 +805,11 @@ function App(props: { onSnapshot?: () => Promise }) { bindings: tuiConfig.keybinds.gather("app", appBindingCommands), })) + useBindings(() => ({ + enabled: () => AppExit.enabled(command.matcher.get(), promptRef.current), // kilocode_change + bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]), + })) + KiloApp.init() // kilocode_change event.on(TuiEvent.CommandExecute.type, (evt) => { @@ -979,9 +973,7 @@ function tryUseTerminalDimensions() { return undefined } } -// kilocode_change end -// kilocode_change start — inlined ErrorComponent with safe renderer/keyboard guards function ErrorComponent(props: { error: Error reset: () => void @@ -999,7 +991,6 @@ function ErrorComponent(props: { renderer?.setTerminalTitle("") renderer?.destroy() win32FlushInputBuffer() - // kilocode_change - reset terminal state to disable mouse tracking on exit resetTerminalState() await props.onExit() } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 0a3ea1ea13de..1525c81c3536 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -57,8 +57,10 @@ import { } from "../dialog-workspace-create" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" -import { KiloSessionTuiSync } from "@/kilocode/session/tui-sync" // kilocode_change -import { slashMatches } from "@/kilocode/cli/cmd/command-display" // kilocode_change +// kilocode_change start +import { KiloSessionTuiSync } from "@/kilocode/session/tui-sync" +import { slashMatches } from "@/kilocode/cli/cmd/command-display" +// kilocode_change end import { Flag } from "@opencode-ai/core/flag/flag" import { type WorkspaceStatus } from "../workspace-label" import { useCommandPalette } from "../../context/command-palette" @@ -391,11 +393,9 @@ export function Prompt(props: PromptProps) { const sessionID = props.sessionID const msg = lastUserMessage() if (!sessionID || !msg) return - // kilocode_change start - skip compaction messages while syncing local agent/model const parts = sync.data.part[msg.id] if (!parts) return if (!KiloSessionTuiSync.model({ role: msg.role, parts })) return - // kilocode_change end const key = [sessionID, msg.id].join(":") if (key === syncedKey) return @@ -729,7 +729,6 @@ export function Prompt(props: PromptProps) { ...input.traits, ...computePromptTraits({ mode: store.mode, - disabled: !!props.disabled, autocompleteVisible: !!auto()?.visible, }), } @@ -1535,11 +1534,9 @@ export function Prompt(props: PromptProps) { syncExtmarksWithPromptParts() setCursorVersion((value) => value + 1) }} - onCursorChange={() => { + /* kilocode_change */ onCursorChange={() => { setCursorVersion((value) => value + 1) - // kilocode_change start - dismiss autocomplete when cursor movement invalidates its filter if (store.mode === "normal") auto()?.onCursorChange() - // kilocode_change end }} onKeyDown={(e: { preventDefault(): void }) => { if (props.disabled) { @@ -1642,7 +1639,7 @@ export function Prompt(props: PromptProps) { string + format: (input?: string) => string +}>() + +export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) { + return ( + props.path || process.cwd(), format: (input) => formatPath(input, props.path) }} + > + {props.children} + + ) +} + +export function usePathFormatter() { + const value = useContext(context) + if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider") + return value +} + +function formatPath(input: string | undefined, base: string | undefined) { + if (!input) return "" + + const root = base || process.cwd() + const absolute = path.isAbsolute(input) ? input : path.resolve(root, input) + const relative = path.relative(root, absolute) + + if (!relative) return "." + if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative + if (Global.Path.home && (absolute === Global.Path.home || absolute.startsWith(Global.Path.home + path.sep))) { + return absolute.replace(Global.Path.home, "~") + } + return absolute +} diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index 379fa5afdfcd..289bb901d6d1 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -8,9 +8,9 @@ import { import { KeymapProvider, reactiveMatcherFromSignal, - useBindings, useKeymap, useKeymapSelector, + useBindings, } from "@opentui/keymap/solid" import type { Accessor } from "solid-js" import type { TuiConfig } from "./config/tui" @@ -26,6 +26,28 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector } export type OpenTuiKeymap = ReturnType +const KEY_ALIASES = { + enter: "return", + esc: "escape", +} as const + +function expandKeyAliases(input: string) { + const result = Object.entries(KEY_ALIASES).reduce( + (acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`), + input, + ) + if (result === input) return + return result +} + +function registerKeyAliases(keymap: OpenTuiKeymap) { + return keymap.appendBindingExpander((ctx) => { + const key = expandKeyAliases(ctx.input) + if (!key) return + return [{ key, displays: ctx.displays }] + }) +} + const inputCommands = [ "input.move.left", "input.move.right", @@ -98,8 +120,13 @@ export function formatKeyBindings( return formatCommandBindingsExtra(bindings, formatOptions(config)) } -export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) { +export function registerOpencodeKeymap( + keymap: OpenTuiKeymap, + renderer: CliRenderer, + config: Pick, +) { const offCommaBindings = addons.registerCommaBindings(keymap) + const offAliasExpander = registerKeyAliases(keymap) const offBaseLayout = addons.registerBaseLayoutFallback(keymap) const offLeader = addons.registerTimedLeader(keymap, { trigger: config.keybinds.get(LEADER_TOKEN), @@ -108,20 +135,17 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende }) const offEscape = addons.registerEscapeClearsPendingSequence(keymap) const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) - const offInputCommands = addons.registerEditBufferCommands(keymap, renderer) - const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer) - const offInputBindings = keymap.registerLayer({ + const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, { enabled: () => renderer.currentFocusedEditor !== null, bindings: config.keybinds.gather("input", inputCommands), }) return () => { offInputBindings() - offInputSuspension() - offInputCommands() offBackspace() offEscape() offLeader() + offAliasExpander() offBaseLayout() offCommaBindings() } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 7fecf96b1ffa..34eca44992e0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -23,8 +23,10 @@ import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { selectedForeground, useTheme } from "@tui/context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" -import type { KeyEvent } from "@opentui/core" // kilocode_change -import type { CommandContext } from "@opentui/keymap" // kilocode_change +// kilocode_change start +import type { KeyEvent } from "@opentui/core" +import type { CommandContext } from "@opentui/keymap" +// kilocode_change end import { Prompt, type PromptRef } from "@tui/component/prompt" // kilocode_change start import type { AssistantMessage, Part, Provider, ToolPart, UserMessage, TextPart, ReasoningPart } from "@kilocode/sdk/v2" @@ -78,24 +80,27 @@ import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" -import { Global } from "@opencode-ai/core/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" -import { Suggest } from "@/kilocode/suggestion/tui/render" // kilocode_change -import { SuggestPrompt } from "@/kilocode/suggestion/tui/prompt" // kilocode_change -import { NetworkPrompt } from "./network" // kilocode_change +// kilocode_change start +import { Suggest } from "@/kilocode/suggestion/tui/render" +import { SuggestPrompt } from "@/kilocode/suggestion/tui/prompt" +import { NetworkPrompt } from "./network" +// kilocode_change end import { DialogExportOptions } from "../../ui/dialog-export-options" import * as Model from "../../util/model" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" -import { splitDiffHunks } from "@/kilocode/tui/diff" // kilocode_change -import { session as banner } from "@/kilocode/cli/logo" // kilocode_change +// kilocode_change start +import { splitDiffHunks } from "@/kilocode/tui/diff" +import { session as banner } from "@/kilocode/cli/logo" -import { formatMarkdownTables } from "../../util/markdown" // kilocode_change -import { bell } from "@/kilocode/bell" // kilocode_change -import { SessionIndexing } from "@/kilocode/components/session-indexing" // kilocode_change -import { submitFeedback } from "@/kilocode/cli/cmd/tui/feedback" // kilocode_change +import { formatMarkdownTables } from "../../util/markdown" +import { bell } from "@/kilocode/bell" +import { SessionIndexing } from "@/kilocode/components/session-indexing" +import { submitFeedback } from "@/kilocode/cli/cmd/tui/feedback" +// kilocode_change end import { getScrollAcceleration } from "../../util/scroll" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { DialogRetryAction } from "../../component/dialog-retry-action" @@ -103,6 +108,7 @@ import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" import { useCommandPalette } from "../../context/command-palette" import { useBindings, useCommandShortcut } from "../../keymap" +import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" addDefaultParsers(parsers.parsers) @@ -229,10 +235,8 @@ export function Session() { const nonBlockingQuestions = createMemo(() => questions().filter((q) => q.blocking === false)) const question = createMemo(() => blockingQuestions()[0] ?? nonBlockingQuestions()[0]) const blockingSuggestions = createMemo(() => suggestions().filter((s) => s.blocking !== false)) - // kilocode_change start - footer overlay only hosts blocking suggestions now; // non-blocking ones render inline at the tool-part slot via `SuggestBar`. const blockingSuggestion = createMemo(() => blockingSuggestions()[0]) - // kilocode_change end const visible = createMemo( () => !session()?.parentID && @@ -263,9 +267,7 @@ export function Session() { const lastAssistant = createMemo(() => { return messages().findLast((x) => x.role === "assistant") }) - // kilocode_change end - // kilocode_change start - ring terminal bell on task completion createEffect( on( () => [route.sessionID, sync.data.session_status?.[route.sessionID]?.type] as const, @@ -275,9 +277,7 @@ export function Session() { }, ), ) - // kilocode_change end - // kilocode_change start - ring terminal bell when input is needed createEffect( on( () => [route.sessionID, permissions().length] as const, @@ -298,7 +298,7 @@ export function Session() { ) createEffect( on( - () => [route.sessionID, suggestions().length + network().length] as const, // kilocode_change + () => [route.sessionID, suggestions().length + network().length] as const, ([id, len], prev) => { if (!prev || prev[0] !== id) return if (len > prev[1] && bellEnabled()) bell() @@ -423,8 +423,8 @@ export function Session() { if (part.state.status !== "completed") return if (part.id === lastSwitch) return - // kilocode_change - plan_exit no longer switches agent; PlanFollowup handles it if (part.tool === "plan_enter") { + // kilocode_change local.agent.set("plan") lastSwitch = part.id } @@ -471,9 +471,7 @@ export function Session() { const title = Locale.truncate(session()?.title ?? "", 50) return exit.message.set(banner(title, session()?.id, UI.Style.TEXT_DIM, UI.Style.TEXT_NORMAL)) }) - // kilocode_change end - // kilocode_change start - double ctrl+c to exit for child sessions const [exitPress, setExitPress] = createSignal(0) useBindings(() => ({ enabled: Boolean(session()?.parentID), @@ -1248,231 +1246,227 @@ export function Session() { createEffect(on(() => route.sessionID, toBottom)) return ( - - - - - (scroll = r)} - viewportOptions={{ - paddingRight: showScrollbar() ? 1 : 0, - }} - verticalScrollbarOptions={{ - paddingLeft: 1, - visible: showScrollbar(), - trackOptions: { - backgroundColor: theme.backgroundElement, - foregroundColor: theme.border, - }, - }} - stickyScroll={true} - stickyStart="bottom" - flexGrow={1} - scrollAcceleration={scrollAcceleration()} - > - - {/* kilocode_change start */} - - - ↳ Initializing... - - - {/* kilocode_change end */} - - {(message, index) => ( - - - {(function () { - const command = useCommandPalette() - const redoShortcut = useCommandShortcut("session.redo") - const [hover, setHover] = createSignal(false) - const dialog = useDialog() - - const handleUnrevert = async () => { - const confirmed = await DialogConfirm.show( - dialog, - "Confirm Redo", - "Are you sure you want to restore the reverted messages?", - ) - if (confirmed) { - command.run("session.redo") + + + + + + (scroll = r)} + viewportOptions={{ + paddingRight: showScrollbar() ? 1 : 0, + }} + verticalScrollbarOptions={{ + paddingLeft: 1, + visible: showScrollbar(), + trackOptions: { + backgroundColor: theme.backgroundElement, + foregroundColor: theme.border, + }, + }} + stickyScroll={true} + stickyStart="bottom" + flexGrow={1} + scrollAcceleration={scrollAcceleration()} + > + + {/* kilocode_change start */} + + + ↳ Initializing... + + + {/* kilocode_change end */} + + {(message, index) => ( + + + {(function () { + const command = useCommandPalette() + const redoShortcut = useCommandShortcut("session.redo") + const [hover, setHover] = createSignal(false) + const dialog = useDialog() + + const handleUnrevert = async () => { + const confirmed = await DialogConfirm.show( + dialog, + "Confirm Redo", + "Are you sure you want to restore the reverted messages?", + ) + if (confirmed) { + command.run("session.redo") + } } - } - - return ( - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={handleUnrevert} - marginTop={1} - flexShrink={0} - border={["left"]} - customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.backgroundPanel} - > + + return ( setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={handleUnrevert} + marginTop={1} + flexShrink={0} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundPanel} > - {revert()!.reverted.length} message reverted - - {redoShortcut()} or /redo to restore - - - - - {(file) => ( - - {file.filename} - 0}> - +{file.additions} - - 0}> - -{file.deletions} - - - )} - - - + + {revert()!.reverted.length} message reverted + + {redoShortcut()} or /redo to restore + + + + + {(file) => ( + + {file.filename} + 0}> + +{file.additions} + + 0}> + -{file.deletions} + + + )} + + + + - - ) - })()} - - = revert()!.messageID}> - <> - - - { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - prompt?.set(promptInfo)} - /> - )) - }} - message={message as UserMessage} - parts={sync.data.part[message.id] ?? []} - pending={pending()} - /> - - - - - - )} - - - - 0}> - - - {/* kilocode_change start */} - - {(request) => ( - prompt?.focused ?? false} - /> - )} - - - {/* kilocode_change end */} + ) + })()} + + = revert()!.messageID}> + <> + + + { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ( + prompt?.set(promptInfo)} + /> + )) + }} + message={message as UserMessage} + parts={sync.data.part[message.id] ?? []} + pending={pending()} + /> + + + + + + )} + + + + 0}> + + {/* kilocode_change start */} - - {(request) => } + + {(request) => ( + prompt?.focused ?? false} + /> + )} - - - - - {/* kilocode_change end */} - {/* kilocode_change start */} - - - - {/* kilocode_change end */} - {/* kilocode_change start */} - - - + + {(request) => } + + + + + + + + + + { - toBottom() - }} - sessionID={route.sessionID} - right={} - /> - - - {/* kilocode_change end */} - + on_submit={toBottom} + ref={bind} + > + { + toBottom() + }} + sessionID={route.sessionID} + right={} + /> + + + {/* kilocode_change end */} + + + {/* kilocode_change start */} + + {/* kilocode_change end */} + + + + + + + + + + + + + - {/* kilocode_change start */} - - {/* kilocode_change end */} - - - - - - - - - - - - - - - + + ) } @@ -2063,7 +2057,7 @@ function BlockTool(props: { function Shell(props: ToolProps) { const { theme } = useTheme() - const sync = useSync() + const pathFormatter = usePathFormatter() const isRunning = createMemo(() => props.part.state.status === "running") const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) @@ -2077,18 +2071,7 @@ function Shell(props: ToolProps) { const workdirDisplay = createMemo(() => { const workdir = props.input.workdir if (!workdir || workdir === ".") return undefined - - const base = sync.path.directory - if (!base) return undefined - - const absolute = path.resolve(base, workdir) - if (absolute === base) return undefined - - const home = Global.Path.home - if (!home) return absolute - - const match = absolute === home || absolute.startsWith(home + path.sep) - return match ? absolute.replace(home, "~") : absolute + return pathFormatter.format(workdir) }) const title = createMemo(() => { @@ -2130,6 +2113,7 @@ function Shell(props: ToolProps) { function Write(props: ToolProps) { const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const code = createMemo(() => { if (!props.input.content) return "" return props.input.content @@ -2138,7 +2122,7 @@ function Write(props: ToolProps) { return ( - + ) { - Write {normalizePath(props.input.filePath!)} + Write {pathFormatter.format(props.input.filePath)} @@ -2161,9 +2145,10 @@ function Write(props: ToolProps) { } function Glob(props: ToolProps) { + const pathFormatter = usePathFormatter() return ( - Glob "{props.input.pattern}" in {normalizePath(props.input.path)} + Glob "{props.input.pattern}" in {pathFormatter.format(props.input.path)} ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"}) @@ -2173,6 +2158,7 @@ function Glob(props: ToolProps) { function Read(props: ToolProps) { const { theme } = useTheme() + const pathFormatter = usePathFormatter() const isRunning = createMemo(() => props.part.state.status === "running") const loaded = createMemo(() => { if (props.part.state.status !== "completed") return [] @@ -2190,13 +2176,13 @@ function Read(props: ToolProps) { spinner={isRunning()} part={props.part} > - Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} + Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])} {(filepath) => ( - ↳ Loaded {normalizePath(filepath)} + ↳ Loaded {pathFormatter.format(filepath)} )} @@ -2206,9 +2192,10 @@ function Read(props: ToolProps) { } function Grep(props: ToolProps) { + const pathFormatter = usePathFormatter() return ( - Grep "{props.input.pattern}" in {normalizePath(props.input.path)} + Grep "{props.input.pattern}" in {pathFormatter.format(props.input.path)} ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"}) @@ -2237,6 +2224,7 @@ function WebSearch(props: ToolProps) { // kilocode_change start function BackgroundProcess(props: ToolProps) { const sync = useSync() + const pathFormatter = usePathFormatter() const running = createMemo(() => props.part.state.status === "running") const cmd = createMemo(() => (typeof props.input.command === "string" ? props.input.command : "")) const action = createMemo(() => props.input.action ?? "start") @@ -2245,10 +2233,10 @@ function BackgroundProcess(props: ToolProps) { const raw = props.input.workdir if (!raw || raw === ".") return undefined const base = sync.path.directory - if (!base) return normalizePath(raw) + if (!base) return pathFormatter.format(raw) const abs = path.resolve(base, raw) if (abs === base) return undefined - return normalizePath(abs) + return pathFormatter.format(abs) }) const status = createMemo(() => { if (typeof props.metadata.status === "string") return props.metadata.status @@ -2281,13 +2269,14 @@ function BackgroundProcess(props: ToolProps) { } function SemanticSearch(props: ToolProps) { + const pathFormatter = usePathFormatter() const meta = createMemo(() => props.metadata as { results?: { length: number }[] }) const args = createMemo(() => props.input as { query?: string; path?: string }) const count = createMemo(() => meta().results?.length ?? 0) return ( - Codebase Search "{args().query}" in {normalizePath(args().path!)} + Codebase Search "{args().query}" in {pathFormatter.format(args().path)} 0}> ({count()} {count() === 1 ? "result" : "results"}) @@ -2374,6 +2363,7 @@ function Task(props: ToolProps) { function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const view = createMemo(() => { const diffStyle = ctx.tui.diff_style @@ -2390,7 +2380,7 @@ function Edit(props: ToolProps) { return ( - + {/* kilocode_change start */} @@ -2430,7 +2420,7 @@ function Edit(props: ToolProps) { - Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })} + Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })} @@ -2440,6 +2430,7 @@ function Edit(props: ToolProps) { function ApplyPatch(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const files = createMemo(() => props.metadata.files ?? []) @@ -2492,7 +2483,7 @@ function ApplyPatch(props: ToolProps) { function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { if (file.type === "delete") return "# Deleted " + file.relativePath if (file.type === "add") return "# Created " + file.relativePath - if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath + if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath return "← Patched " + file.relativePath } @@ -2612,20 +2603,6 @@ function Diagnostics(props: { diagnostics?: Record[] ) } -function normalizePath(input?: string) { - if (!input) return "" - - const cwd = process.cwd() - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - - if (!relative) return "." - if (!relative.startsWith("..")) return relative - - // outside cwd - use absolute - return absolute -} - function input(input: Record, omit?: string[]): string { const primitives = Object.entries(input).filter(([key, value]) => { if (omit?.includes(key)) return false diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 03d24d51592a..b3875aec3722 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -11,37 +11,21 @@ import { useProject } from "../../context/project" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Locale } from "@/util/locale" -import { Global } from "@opencode-ai/core/global" import { ShellID } from "@/tool/shell/id" import { webSearchProviderLabel } from "@/tool/websearch" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" -import { ConfigProtection } from "@/kilocode/permission/config-paths" // kilocode_change -import { splitDiffHunks } from "@/kilocode/tui/diff" // kilocode_change -import { normalizeUrls } from "@/kilocode/util/url" // kilocode_change +// kilocode_change start +import { ConfigProtection } from "@/kilocode/permission/config-paths" +import { splitDiffHunks } from "@/kilocode/tui/diff" +import { normalizeUrls } from "@/kilocode/util/url" +// kilocode_change end import { useBindings, useCommandShortcut } from "../../keymap" +import { usePathFormatter } from "../../context/path-format" type PermissionStage = "permission" | "always" | "reject" -function normalizePath(input?: string) { - if (!input) return "" - - const cwd = process.cwd() - const home = Global.Path.home - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - - if (!relative) return "." - if (!relative.startsWith("..")) return relative - - // outside cwd - use ~ or absolute - if (home && (absolute === home || absolute.startsWith(home + path.sep))) { - return absolute.replace(home, "~") - } - return absolute -} - function filetype(input?: string) { if (!input) return "none" const ext = path.extname(input) @@ -154,6 +138,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const [store, setStore] = createStore({ stage: "permission" as PermissionStage, }) + const pathFormatter = usePathFormatter() const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) @@ -239,7 +224,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const filepath = typeof raw === "string" ? raw : "" return { icon: "→", - title: `Edit ${normalizePath(filepath)}`, + title: `Edit ${pathFormatter.format(filepath)}`, body: , } } @@ -249,11 +234,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const filePath = typeof raw === "string" ? raw : "" return { icon: "→", - title: `Read ${normalizePath(filePath)}`, + title: `Read ${pathFormatter.format(filePath)}`, body: ( - {"Path: " + normalizePath(filePath)} + {"Path: " + pathFormatter.format(filePath)} ), @@ -295,11 +280,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const dir = typeof raw === "string" ? raw : "" return { icon: "→", - title: `List ${normalizePath(dir)}`, + title: `List ${pathFormatter.format(dir)}`, body: ( - {"Path: " + normalizePath(dir)} + {"Path: " + pathFormatter.format(dir)} ), @@ -389,7 +374,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined const raw = parent ?? filepath ?? derived - const dir = normalizePath(raw) + const dir = pathFormatter.format(raw) const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") return { @@ -445,13 +430,13 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { {current.title} - {/* // kilocode_change start - explain config file edits always require approval */} + {/* kilocode_change start - explain config file edits always require approval */} Config file edits always require approval - {/* // kilocode_change end */} + {/* kilocode_change end */} ) @@ -466,7 +451,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { title="Permission required" header={header()} body={current.body} - options={options} + /* kilocode_change */ options={options} escapeKey="reject" fullscreen onSelect={(option) => { @@ -546,7 +531,6 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( Reject permission - {/* kilocode_change */} Tell Kilo what to do differently diff --git a/packages/opencode/src/cli/cmd/tui/validate-session.ts b/packages/opencode/src/cli/cmd/tui/validate-session.ts index da94eb9ca24b..dd37a6715884 100644 --- a/packages/opencode/src/cli/cmd/tui/validate-session.ts +++ b/packages/opencode/src/cli/cmd/tui/validate-session.ts @@ -1,5 +1,8 @@ import { createKiloClient } from "@kilocode/sdk/v2" import { SessionID } from "@/session/schema" +import { Schema } from "effect" + +const decodeSessionID = Schema.decodeUnknownSync(SessionID) export async function validateSession(input: { url: string @@ -10,9 +13,11 @@ export async function validateSession(input: { }) { if (!input.sessionID) return - const result = SessionID.zod.safeParse(input.sessionID) - if (!result.success) { - throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`) + let sessionID: SessionID + try { + sessionID = decodeSessionID(input.sessionID) + } catch (error) { + throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error }) } await createKiloClient({ @@ -20,5 +25,5 @@ export async function validateSession(input: { directory: input.directory, fetch: input.fetch, headers: input.headers, - }).session.get({ sessionID: result.data }, { throwOnError: true }) + }).session.get({ sessionID }, { throwOnError: true }) } diff --git a/packages/opencode/src/config/attachment.ts b/packages/opencode/src/config/attachment.ts new file mode 100644 index 000000000000..7af429afdee7 --- /dev/null +++ b/packages/opencode/src/config/attachment.ts @@ -0,0 +1,30 @@ +export * as ConfigAttachment from "./attachment" + +import { Schema } from "effect" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" + +export const Image = Schema.Struct({ + auto_resize: Schema.optional(Schema.Boolean).annotate({ + description: "Resize images before sending them to the model when they exceed configured limits (default: true)", + }), + max_width: Schema.optional(PositiveInt).annotate({ + description: "Maximum image width before resizing or rejecting the attachment (default: 2000)", + }), + max_height: Schema.optional(PositiveInt).annotate({ + description: "Maximum image height before resizing or rejecting the attachment (default: 2000)", + }), + max_base64_bytes: Schema.optional(PositiveInt).annotate({ + description: "Maximum base64 payload bytes for an image attachment (default: 4718592)", + }), +}) + .annotate({ identifier: "ImageAttachmentConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Image = Schema.Schema.Type + +export const Info = Schema.Struct({ + image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }), +}) + .annotate({ identifier: "AttachmentConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f6851e4a0649..a54acf89a38c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -29,6 +29,7 @@ import { containsPath } from "../project/instance-context" import { zod } from "@opencode-ai/core/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema" import { ConfigAgent } from "./agent" +import { ConfigAttachment } from "./attachment" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" import { ConfigLayout } from "./layout" @@ -218,12 +219,10 @@ export const Info = Schema.Struct({ auto_collapse_reasoning: Schema.optional(Schema.Boolean).annotate({ description: "Automatically collapse reasoning blocks after the agent finishes writing them", }), - indexing: Schema.optional(IndexingRef).annotate({ description: "Codebase indexing configuration" }), // kilocode_change + indexing: Schema.optional(IndexingRef).annotate({ description: "Codebase indexing configuration" }), terminal_command_display: Schema.optional(Schema.Literals(["expanded", "collapsed"])).annotate({ description: "Controls whether terminal command blocks are expanded or collapsed by default in the VS Code chat UI", }), - // kilocode_change end - // kilocode_change start - nullable for delete sentinel model: Schema.optional(Schema.NullOr(ConfigModelID)).annotate({ description: "Model to use in the format of provider/model, eg anthropic/claude-2", }), @@ -237,8 +236,6 @@ export const Info = Schema.Struct({ subagent_variant: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "Default model variant for task-tool subagents when subagent_model is configured.", }), - // kilocode_change end - // kilocode_change start - renamed from "build" to "code" + nullable for delete sentinel default_agent: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "Default agent to use when none is specified. Must be a primary agent. Falls back to 'code' if not set or if the specified agent is invalid.", @@ -262,9 +259,11 @@ export const Info = Schema.Struct({ // primary plan: Schema.optional(ConfigAgent.Info), build: Schema.optional(ConfigAgent.Info), - debug: Schema.optional(ConfigAgent.Info), // kilocode_change - orchestrator: Schema.optional(ConfigAgent.Info), // kilocode_change - ask: Schema.optional(ConfigAgent.Info), // kilocode_change + // kilocode_change start + debug: Schema.optional(ConfigAgent.Info), + orchestrator: Schema.optional(ConfigAgent.Info), + ask: Schema.optional(ConfigAgent.Info), + // kilocode_change end // subagent general: Schema.optional(ConfigAgent.Info), explore: Schema.optional(ConfigAgent.Info), @@ -276,9 +275,10 @@ export const Info = Schema.Struct({ }), [Schema.Record(Schema.String, ConfigAgent.Info)], ), + // kilocode_change start ).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }), provider: Schema.optional(Schema.Record(Schema.String, Schema.NullOr(ConfigProvider.Info))).annotate({ - // kilocode_change - nullable for delete sentinel + // kilocode_change end description: "Custom provider configurations and model overrides", }), mcp: Schema.optional( @@ -305,6 +305,9 @@ export const Info = Schema.Struct({ layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), permission: Schema.optional(ConfigPermission.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + attachment: Schema.optional(ConfigAttachment.Info).annotate({ + description: "Attachment processing configuration, including image size limits and resizing behavior", + }), enterprise: Schema.optional( Schema.Struct({ url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }), @@ -354,13 +357,11 @@ export const Info = Schema.Struct({ Schema.Struct({ disable_paste_summary: Schema.optional(Schema.Boolean), batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }), - codebase_search: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI-powered codebase search" }), // kilocode_change // kilocode_change start + codebase_search: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI-powered codebase search" }), speech_to_text_model: Schema.optional(Schema.String).annotate({ description: "Speech-to-text transcription model ID to use for voice input", }), - // kilocode_change end - // kilocode_change start - enable telemetry by default openTelemetry: Schema.Boolean.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(true))).annotate({ description: "Enable telemetry. Set to false to opt-out.", }), @@ -408,10 +409,12 @@ export interface Interface { readonly getGlobal: () => Effect.Effect readonly getConsoleState: () => Effect.Effect readonly update: (config: Info) => Effect.Effect + // kilocode_change start readonly updateGlobal: ( config: Info, options?: { dispose?: boolean }, - ) => Effect.Effect<{ info: Info; changed: boolean }> // kilocode_change + ) => Effect.Effect<{ info: Info; changed: boolean }> + // kilocode_change end readonly invalidate: () => Effect.Effect readonly directories: () => Effect.Effect readonly waitForDependencies: () => Effect.Effect @@ -434,8 +437,8 @@ function globalConfigFile() { function patchJsonc(input: string, patch: unknown, path: string[] = []): string { if (!isRecord(patch)) { - // kilocode_change - null means "delete this key" — pass undefined to jsonc-parser's modify() const edits = modify(input, path, patch === null ? undefined : patch, { + // kilocode_change formattingOptions: { insertSpaces: true, tabSize: 2, @@ -515,8 +518,10 @@ export const layer = Layer.effect( yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) if (!data.$schema) { - data.$schema = "https://app.kilo.ai/config.json" // kilocode_change - const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://app.kilo.ai/config.json",') // kilocode_change + // kilocode_change start + data.$schema = "https://app.kilo.ai/config.json" + const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://app.kilo.ai/config.json",') + // kilocode_change end yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) } return data @@ -532,8 +537,10 @@ export const layer = Layer.effect( let globalStamp = "" // kilocode_change const loadGlobal = Effect.fnUntraced(function* () { - yield* Effect.promise(() => KilocodeConfig.migrateBashPermission()) // kilocode_change - globalStamp = yield* KilocodeGlobalConfigStamp.read(fs, Global.Path.config) // kilocode_change + // kilocode_change start + yield* Effect.promise(() => KilocodeConfig.migrateBashPermission()) + globalStamp = yield* KilocodeGlobalConfigStamp.read(fs, Global.Path.config) + // kilocode_change end let result: Info = {} result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"))) // kilocode_change start @@ -682,8 +689,8 @@ export const layer = Layer.effect( for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { const url = key.replace(/\/+$/, "") - const source = `${url}/.well-known/opencode` // kilocode_change - // kilocode_change start - warn instead of fail on wellknown errors + // kilocode_change start + const source = `${url}/.well-known/opencode` yield* Effect.gen(function* () { process.env[value.key] = value.token log.debug("fetching remote config", { url: `${url}/.well-known/opencode` }) @@ -713,7 +720,7 @@ export const layer = Layer.effect( })) as Record) : {} const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info) - if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" // kilocode_change + if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" const next = yield* loadConfig(JSON.stringify(remoteConfig), { dir: path.dirname(source), source, @@ -1047,10 +1054,8 @@ export const layer = Layer.effect( }, }), ) - // kilocode_change end }) - // kilocode_change start const warnings = Effect.fn("Config.warnings")(function* () { return yield* InstanceState.use(state, (s) => s.warnings) }) @@ -1072,8 +1077,7 @@ export const layer = Layer.effect( let changed: boolean if (!file.endsWith(".jsonc")) { const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file) - // kilocode_change - use `patch` (writableGlobal) so empty-string sentinels are stripped via undefined - const merged = KilocodeConfig.mergeConfig(writable(existing), patch) + const merged = KilocodeConfig.mergeConfig(writable(existing), patch) // kilocode_change const serialized = JSON.stringify(merged, null, 2) changed = serialized !== before if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie) diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index dd4c325490ff..1954543f4afe 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,18 +1,14 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( - Schema.brand("WorkspaceID"), -) +const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID")) export type WorkspaceID = typeof workspaceIdSchema.Type export const WorkspaceID = workspaceIdSchema.pipe( withStatics((schema: typeof workspaceIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)), - zod: zod(schema), })), ) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index f8a2590f489f..7d72a839474a 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -676,14 +676,12 @@ export const layer = Layer.effect( } if (input.workspaceID === null) { - yield* Effect.sync(() => - SyncEvent.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { - workspaceID: null, - }, - }), - ) + yield* sync.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: null, + }, + }) log.info("session warp complete", { workspaceID: input.workspaceID, diff --git a/packages/opencode/src/data-migration.sql.ts b/packages/opencode/src/data-migration.sql.ts new file mode 100644 index 000000000000..ba446b501cec --- /dev/null +++ b/packages/opencode/src/data-migration.sql.ts @@ -0,0 +1,6 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" + +export const DataMigrationTable = sqliteTable("data_migration", { + name: text().primaryKey(), + time_completed: integer().notNull(), +}) diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts new file mode 100644 index 000000000000..c3e5a9d2b0fd --- /dev/null +++ b/packages/opencode/src/data-migration.ts @@ -0,0 +1,59 @@ +import { Context, Effect, Layer } from "effect" +import { Database } from "./storage/db" +import { DataMigrationTable } from "./data-migration.sql" +import * as Log from "@opencode-ai/core/util/log" +import { eq } from "drizzle-orm" + +export type Migration = { + name: string + run: Effect.Effect +} + +const log = Log.create({ service: "data-migration" }) + +export interface Interface {} + +export class Service extends Context.Service()("@opencode/DataMigration") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const migrations: Migration[] = [] + + yield* Effect.gen(function* () { + if (migrations.length === 0) return + + // Migrations run in a background fiber, so they must be resumable until + // their completion row is written. + for (const migration of migrations) { + const completed = Database.use((db) => + db + .select({ name: DataMigrationTable.name }) + .from(DataMigrationTable) + .where(eq(DataMigrationTable.name, migration.name)) + .get(), + ) + if (completed) continue + + log.info("running data migration", { name: migration.name }) + yield* migration.run + Database.use((db) => + db + .insert(DataMigrationTable) + .values({ name: migration.name, time_completed: Date.now() }) + .onConflictDoNothing() + .run(), + ) + } + }).pipe( + Effect.tapCause((cause) => Effect.logError("failed to run data migrations", { cause })), + Effect.ignore, + Effect.forkScoped, + ) + return Service.of({}) + }), +) + +export const defaultLayer = layer + +export * as DataMigration from "./data-migration" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 995942819fcc..377b465547dd 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -44,6 +44,7 @@ import { Format } from "@/format" import { InstanceLayer } from "@/project/instance-layer" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" +import { Reference } from "@/reference/reference" import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Pty } from "@/pty" @@ -54,6 +55,7 @@ import { SessionShare } from "@/share/session" import { SyncEvent } from "@/sync" import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" +import { DataMigration } from "@/data-migration" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, @@ -98,6 +100,7 @@ export const AppLayer = Layer.mergeAll( Format.defaultLayer, Project.defaultLayer, Vcs.defaultLayer, + Reference.defaultLayer, Workspace.defaultLayer, Worktree.appLayer, Pty.defaultLayer, @@ -106,6 +109,7 @@ export const AppLayer = Layer.mergeAll( ShareNext.defaultLayer, SessionShare.defaultLayer, SyncEvent.defaultLayer, + DataMigration.defaultLayer, ).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index bc0baceb8207..47848e2d6d6f 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -1,4 +1,3 @@ -import z from "zod" import { randomBytes } from "crypto" const prefixes = { @@ -8,19 +7,12 @@ const prefixes = { permission: "per", question: "que", suggestion: "sug", // kilocode_change - user: "usr", part: "prt", pty: "pty", tool: "tool", workspace: "wrk", - entry: "ent", - account: "act", } as const -export function schema(prefix: keyof typeof prefixes) { - return z.string().startsWith(prefixes[prefix]) -} - const LENGTH = 26 // State for monotonic ID generation diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts new file mode 100644 index 000000000000..a696e5d77198 --- /dev/null +++ b/packages/opencode/src/image/image.ts @@ -0,0 +1,278 @@ +import { Config } from "@/config/config" +import type { MessageV2 } from "@/session/message-v2" +import * as Log from "@opencode-ai/core/util/log" +import { Context, Effect, Layer, Schema } from "effect" + +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()( + "ImagePhotonUnavailableError", + {}, +) { + override get message() { + return "Photon image processor is unavailable" + } +} + +export class InvalidDataUrlError extends Schema.TaggedErrorClass()("ImageInvalidDataUrlError", { + url: Schema.String, +}) { + override get message() { + return "Image URL must be a base64 data URL" + } +} + +export class DecodeError extends Schema.TaggedErrorClass()("ImageDecodeError", {}) { + override get message() { + return "Image could not be decoded" + } +} + +export class SizeError extends Schema.TaggedErrorClass()("ImageSizeError", { + bytes: Schema.Number, + max: Schema.Number, + width: Schema.Number, + height: Schema.Number, + max_width: Schema.Number, + max_height: Schema.Number, +}) { + override get message() { + return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes` + } +} + +export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError + +export interface Interface { + readonly normalize: (input: MessageV2.FilePart) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Image") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const loadPhoton = yield* Effect.cached( + Effect.promise(async () => { + try { + const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })) + .default + // 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 (err) { + log.error("failed to load Photon image processor", { err }) // kilocode_change + return null + } + }), + ) + + const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) { + const image = (yield* config.get()).attachment?.image + const info = { + autoResize: image?.auto_resize ?? AUTO_RESIZE, + maxWidth: image?.max_width ?? MAX_WIDTH, + maxHeight: image?.max_height ?? MAX_HEIGHT, + maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES, + } + if (!input.url.startsWith("data:") || !input.url.includes(";base64,")) + return yield* new InvalidDataUrlError({ url: input.url }) + + const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length) + const photon = yield* loadPhoton + // 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 { + return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")) + } catch { + return undefined + } + }) + if (!decoded) return yield* new DecodeError() + + try { + const originalWidth = decoded.get_width() + const originalHeight = decoded.get_height() + if ( + originalWidth <= info.maxWidth && + originalHeight <= info.maxHeight && + Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes + ) + return input + if (!info.autoResize) + return yield* new SizeError({ + bytes: Buffer.byteLength(base64, "utf8"), + max: info.maxBase64Bytes, + width: originalWidth, + height: originalHeight, + max_width: info.maxWidth, + max_height: info.maxHeight, + }) + + const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight) + for (const size of Array.from({ length: 32 }).reduce>((acc) => { + const previous = acc.at(-1) ?? { + width: Math.max(1, Math.round(originalWidth * scale)), + height: Math.max(1, Math.round(originalHeight * scale)), + } + const next = + acc.length === 0 + ? previous + : { + width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)), + height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)), + } + return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next] + }, [])) { + const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3) + const candidate = [ + { data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" }, + ...JPEG_QUALITIES.map((quality) => ({ + data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"), + mime: "image/jpeg", + })), + ] + .map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") })) + .find((item) => item.bytes <= info.maxBase64Bytes) + resized.free() + + if (candidate) { + log.info("using resized image", { + from_mime: input.mime, + to_mime: candidate.mime, + from: `${originalWidth}x${originalHeight}`, + to: `${size.width}x${size.height}`, + }) + return { + ...input, + mime: candidate.mime, + url: `data:${candidate.mime};base64,${candidate.data}`, + } + } + } + + return yield* new SizeError({ + bytes: Buffer.byteLength(base64, "utf8"), + max: info.maxBase64Bytes, + width: originalWidth, + height: originalHeight, + max_width: info.maxWidth, + max_height: info.maxHeight, + }) + } finally { + decoded.free() + } + }) + + return Service.of({ normalize }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + +export * as Image from "./image" diff --git a/packages/opencode/src/kilocode/session/compaction.ts b/packages/opencode/src/kilocode/session/compaction.ts new file mode 100644 index 000000000000..ddd31548b6bd --- /dev/null +++ b/packages/opencode/src/kilocode/session/compaction.ts @@ -0,0 +1,42 @@ +import { Effect } from "effect" +import type { ModelID, ProviderID } from "@/provider/schema" +import type { MessageV2 } from "@/session/message-v2" +import { MessageID, PartID, type SessionID } from "@/session/schema" +import { KiloSessionPromptQueue } from "./prompt-queue" + +export namespace KiloSessionCompaction { + type Store = { + updateMessage: (msg: T) => Effect.Effect + updatePart: (part: T) => Effect.Effect + } + + export function create(input: { + session: Store + sessionID: SessionID + agent: string + model: { providerID: ProviderID; modelID: ModelID } + auto: boolean + overflow?: boolean + }) { + return Effect.gen(function* () { + const msg = yield* input.session.updateMessage({ + id: MessageID.ascending(), + role: "user", + model: input.model, + sessionID: input.sessionID, + agent: input.agent, + time: { created: Date.now() }, + }) + yield* input.session.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: msg.sessionID, + type: "compaction", + auto: input.auto, + overflow: input.overflow, + }) + KiloSessionPromptQueue.retarget(input.sessionID, msg.id) + return msg + }) + } +} diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index 59c98b46739f..d970c1086180 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -1,4 +1,3 @@ -// kilocode_change - new file import { remapChildren as _remapChildren } from "./fork" import z from "zod" import { Cause, Effect, Schema } from "effect" @@ -16,6 +15,7 @@ import { SessionTable } from "@/session/session.sql" import * as Log from "@opencode-ai/core/util/log" import type { LanguageModelUsage, ProviderMetadata } from "ai" import type { Provider } from "@/provider/provider" +import { zod as toZod } from "@opencode-ai/core/effect-zod" import { ENV_FEATURE } from "@kilocode/kilo-gateway" export namespace KiloSession { @@ -403,7 +403,7 @@ export namespace KiloSession { } export const kiloSessionFork = fn( - z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }), + z.object({ sessionID: toZod(SessionID), messageID: toZod(MessageID).optional() }), async (input) => { const { AppRuntime } = await import("@/effect/app-runtime") return AppRuntime.runPromise( diff --git a/packages/opencode/src/kilocode/suggestion/index.ts b/packages/opencode/src/kilocode/suggestion/index.ts index 44c23c6da166..19e82469dc38 100644 --- a/packages/opencode/src/kilocode/suggestion/index.ts +++ b/packages/opencode/src/kilocode/suggestion/index.ts @@ -2,7 +2,7 @@ import { Bus } from "../../bus" import { BusEvent } from "../../bus/bus-event" import { Identifier } from "../../id/id" import { SessionID } from "../../session/schema" -import { ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod as toZod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" import { Telemetry } from "@kilocode/kilo-telemetry" import z from "zod" @@ -34,7 +34,9 @@ export namespace Suggestion { }), }) - const SuggestionIDSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("suggestion") }) + const SuggestionIDSchema = Schema.String.check(Schema.isStartsWith("sug")) + const SuggestionID = toZod(SuggestionIDSchema) + const SessionIDZod = toZod(SessionID) export const Info = z .object({ @@ -48,8 +50,8 @@ export namespace Suggestion { export const Request = z .object({ - id: Identifier.schema("suggestion"), - sessionID: Identifier.schema("session"), + id: SuggestionID, + sessionID: SessionIDZod, text: z.string().describe("Suggestion text shown to the user"), actions: z.array(Action).min(1).max(2).describe("Available actions the user can take"), blocking: z @@ -109,7 +111,6 @@ export namespace Suggestion { ), } - // kilocode_change - Instance.state() removed in v1.4.4; use module-level state // (request IDs are globally unique so instance scoping is not needed) const pending: Record< string, @@ -143,7 +144,7 @@ export namespace Suggestion { return new Promise((resolve, reject) => { const info: Request = { id, - sessionID: input.sessionID, + sessionID: SessionID.make(input.sessionID), text: input.text, actions: input.actions, blocking: input.blocking, diff --git a/packages/opencode/src/kilocode/tui/app-exit.ts b/packages/opencode/src/kilocode/tui/app-exit.ts new file mode 100644 index 000000000000..1e8bc9bcf9d6 --- /dev/null +++ b/packages/opencode/src/kilocode/tui/app-exit.ts @@ -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", + } +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index cbc14bb208c5..acb016ba71fd 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -17,11 +17,12 @@ import os from "os" import z from "zod" // kilocode_change import { evaluate as evalRule } from "./evaluate" import { PermissionID } from "./schema" -import { ConfigProtection } from "@/kilocode/permission/config-paths" // kilocode_change -import { Identifier } from "@/id/id" // kilocode_change -import { drainCovered } from "@/kilocode/permission/drain" // kilocode_change -import { ReadPermission } from "@/kilocode/permission/read" // kilocode_change -import { ExternalDirectoryPermission } from "@/kilocode/permission/external-directory" // kilocode_change +// kilocode_change start +import { ConfigProtection } from "@/kilocode/permission/config-paths" +import { drainCovered } from "@/kilocode/permission/drain" +import { ReadPermission } from "@/kilocode/permission/read" +import { ExternalDirectoryPermission } from "@/kilocode/permission/external-directory" +// kilocode_change end const log = Log.create({ service: "permission" }) @@ -137,15 +138,15 @@ export type ReplyInput = Schema.Schema.Type // kilocode_change start export const SaveAlwaysRulesInput = z.object({ - requestID: PermissionID.zod, + requestID: zod(PermissionID), approvedAlways: z.string().array().optional(), deniedAlways: z.string().array().optional(), }) export const AllowEverythingInput = z.object({ enable: z.boolean(), - requestID: Identifier.schema("permission").optional(), - sessionID: Identifier.schema("session").optional(), + requestID: zod(PermissionID).optional(), + sessionID: zod(SessionID).optional(), }) // kilocode_change end @@ -153,16 +154,20 @@ export interface Interface { readonly ask: (input: AskInput) => Effect.Effect readonly reply: (input: ReplyInput) => Effect.Effect // kilocode_change readonly list: () => Effect.Effect> - readonly saveAlwaysRules: (input: z.infer) => Effect.Effect // kilocode_change - readonly allowEverything: (input: z.infer) => Effect.Effect // kilocode_change - readonly pending: (id: string) => Effect.Effect // kilocode_change + // kilocode_change start + readonly saveAlwaysRules: (input: z.infer) => Effect.Effect + readonly allowEverything: (input: z.infer) => Effect.Effect + readonly pending: (id: string) => Effect.Effect + // kilocode_change end } interface PendingEntry { info: Request - ruleset: Ruleset // kilocode_change - hardRuleset?: Ruleset // kilocode_change - saved?: boolean // kilocode_change + // kilocode_change start + ruleset: Ruleset + hardRuleset?: Ruleset + saved?: boolean + // kilocode_change end deferred: Deferred.Deferred } @@ -190,9 +195,7 @@ export function resolve(permission: string, pattern: string, ruleset: Ruleset, . if (saved.action === "allow") return saved return base } -// kilocode_change end -// kilocode_change start function veto(permission: string, pattern: string, ruleset?: Ruleset) { if (!ruleset) return false return ExternalDirectoryPermission.evaluate(permission, pattern, ruleset).action === "deny" @@ -244,9 +247,11 @@ export const layer = Layer.effect( const ask = Effect.fn("Permission.ask")(function* (input: AskInput) { const { approved, pending } = yield* InstanceState.get(state) - const { ruleset, hardRuleset, ...request } = input // kilocode_change - const s = yield* InstanceState.get(state) // kilocode_change - const local = s.session[request.sessionID] ?? [] // kilocode_change + // kilocode_change start + const { ruleset, hardRuleset, ...request } = input + const s = yield* InstanceState.get(state) + const local = s.session[request.sessionID] ?? [] + // kilocode_change end let needsAsk = false // kilocode_change start — force "ask" for config file edits @@ -333,7 +338,7 @@ export const layer = Layer.effect( if (input.reply === "once") return true // kilocode_change // kilocode_change start — downgrade "always" to "once" for config file edits - if (ConfigProtection.isRequest(existing.info)) return true // kilocode_change + if (ConfigProtection.isRequest(existing.info)) return true // kilocode_change end for (const pattern of existing.info.always) { @@ -345,14 +350,10 @@ export const layer = Layer.effect( action: "allow", }) } - // kilocode_change end } - // kilocode_change start — drain covered permissions across sibling Agent Manager sessions yield* drainCovered(pending as unknown as Map, approved, DeniedError) - // kilocode_change end - // kilocode_change start — persist always-rules to global config if (!existing.saved) { const alwaysRules: Ruleset = existing.info.always.map((pattern) => ({ permission: existing.info.permission, @@ -363,8 +364,7 @@ export const layer = Layer.effect( yield* config.updateGlobal({ permission: toConfig(alwaysRules) }, { dispose: false }) } } - // kilocode_change end - return true // kilocode_change + return true }) const list = Effect.fn("Permission.list")(function* () { @@ -372,15 +372,14 @@ export const layer = Layer.effect( return Array.from(pending.values(), (item) => item.info) }) - // kilocode_change start const saveAlwaysRules = Effect.fn("Permission.saveAlwaysRules")(function* ( input: z.infer, ) { const s = yield* InstanceState.get(state) const existing = s.pending.get(input.requestID) - if (!existing) return false // kilocode_change + if (!existing) return false - if (ConfigProtection.isRequest(existing.info)) return true // kilocode_change + if (ConfigProtection.isRequest(existing.info)) return true const validRules = new Set([ ...((existing.info.metadata?.rules as string[] | undefined) ?? []), @@ -396,7 +395,7 @@ export const layer = Layer.effect( if (deniedSet.has(pattern)) newRules.push({ permission, pattern, action: "deny" }) } s.approved.push(...newRules) - existing.saved = true // kilocode_change + existing.saved = true if (newRules.length > 0) { yield* config.updateGlobal({ permission: toConfig(newRules) }, { dispose: false }) @@ -431,10 +430,10 @@ export const layer = Layer.effect( else s.approved.push(rule) if (input.requestID) { - const entry = s.pending.get(PermissionID.make(input.requestID)) - const ok = entry ? covered(entry, s.approved, s.session[entry.info.sessionID] ?? []) : false // kilocode_change + const entry = s.pending.get(input.requestID) + const ok = entry ? covered(entry, s.approved, s.session[entry.info.sessionID] ?? []) : false if (entry && ok && (!input.sessionID || entry.info.sessionID === input.sessionID)) { - s.pending.delete(PermissionID.make(input.requestID)) + s.pending.delete(input.requestID) yield* bus.publish(Event.Replied, { sessionID: entry.info.sessionID, requestID: entry.info.id, @@ -446,7 +445,7 @@ export const layer = Layer.effect( for (const [id, entry] of s.pending) { if (input.sessionID && entry.info.sessionID !== input.sessionID) continue - if (!covered(entry, s.approved, s.session[entry.info.sessionID] ?? [])) continue // kilocode_change + if (!covered(entry, s.approved, s.session[entry.info.sessionID] ?? [])) continue s.pending.delete(id) yield* bus.publish(Event.Replied, { sessionID: entry.info.sessionID, @@ -463,7 +462,7 @@ export const layer = Layer.effect( }) // kilocode_change end - return Service.of({ ask, reply, list, saveAlwaysRules, allowEverything, pending }) + return Service.of({ ask, reply, list, saveAlwaysRules, allowEverything, pending }) // kilocode_change }), ) diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index 725030935dbb..f7c6e2c5b75c 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,12 +1,12 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { Newtype } from "@opencode-ai/core/schema" export class PermissionID extends Newtype()( "PermissionID", - Schema.String.check(Schema.isStartsWith("per")).annotate({ [ZodOverride]: Identifier.schema("permission") }), + Schema.String.check(Schema.isStartsWith("per")), ) { static ascending(id?: string): PermissionID { return this.make(Identifier.ascending("permission", id)) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index f6086a1752de..3fed2dd60735 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -8,11 +8,14 @@ import * as Vcs from "./vcs" import { Bus } from "../bus" import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" -import { KilocodeBootstrap } from "@/kilocode/bootstrap" // kilocode_change -// import { ShareNext } from "@/share/share-next" // kilocode_change - handled by KilocodeBootstrap +// kilocode_change start +import { KilocodeBootstrap } from "@/kilocode/bootstrap" +// import { ShareNext } from "@/share/share-next" +// kilocode_change end import { Effect, Layer } from "effect" import { Config } from "@/config/config" import { Service } from "./bootstrap-service" +import { Reference } from "@/reference/reference" export { Service } from "./bootstrap-service" export type { Interface } from "./bootstrap-service" @@ -30,8 +33,11 @@ export const layer = Layer.effect( const lsp = yield* LSP.Service const plugin = yield* Plugin.Service const project = yield* Project.Service - const kilocode = yield* KilocodeBootstrap.Service // kilocode_change - Kilo session bootstrap replaces ShareNext - // const shareNext = yield* ShareNext.Service // kilocode_change - handled by KilocodeBootstrap + const reference = yield* Reference.Service + // kilocode_change start + const kilocode = yield* KilocodeBootstrap.Service + // const shareNext = yield* ShareNext.Service + // kilocode_change end const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service @@ -46,7 +52,7 @@ export const layer = Layer.effect( // Each service self-manages its own slow work via Effect.forkScoped against // its per-instance state scope. We just await materialization here. yield* Effect.forEach( - [lsp, format, file, fileWatcher, vcs, snapshot, project], // kilocode_change - shareNext removed, handled by KilocodeBootstrap + [reference, lsp, format, file, fileWatcher, vcs, snapshot, project], // kilocode_change - shareNext removed, handled by KilocodeBootstrap (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), { concurrency: "unbounded", discard: true }, ).pipe(Effect.withSpan("InstanceBootstrap.init")) @@ -66,8 +72,11 @@ export const defaultLayer: Layer.Layer = layer.pipe( LSP.defaultLayer, Plugin.defaultLayer, Project.defaultLayer, - KilocodeBootstrap.defaultLayer, // kilocode_change - Kilo session bootstrap replaces ShareNext - // ShareNext.defaultLayer, // kilocode_change - handled by KilocodeBootstrap + Reference.defaultLayer, + // kilocode_change start + KilocodeBootstrap.defaultLayer, + // ShareNext.defaultLayer, + // kilocode_change end Snapshot.defaultLayer, Vcs.defaultLayer, ]), diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index 0f1d6996df5b..fadb0457e715 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID")) +const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) export type PtyID = typeof ptyIdSchema.Type diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index c18eca3e23e2..1856c94bc70a 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,13 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { Newtype } from "@opencode-ai/core/schema" -export class QuestionID extends Newtype()( - "QuestionID", - Schema.String.check(Schema.isStartsWith("que")).annotate({ [ZodOverride]: Identifier.schema("question") }), -) { +export class QuestionID extends Newtype()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) { static ascending(id?: string): QuestionID { return this.make(Identifier.ascending("question", id)) } diff --git a/packages/opencode/src/reference/reference.ts b/packages/opencode/src/reference/reference.ts new file mode 100644 index 000000000000..cc05fbee02de --- /dev/null +++ b/packages/opencode/src/reference/reference.ts @@ -0,0 +1,237 @@ +import path from "path" +import { Effect, Context, Layer, Scope } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { Git } from "@/git" +import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository" +import { RepositoryCache } from "./repository-cache" + +type ReferenceEntry = NonNullable[string] + +export type Resolved = + | { + name: string + kind: "local" + path: string + } + | { + name: string + kind: "git" + repository: string + reference: RepositoryReference + path: string + branch?: string + } + | { + name: string + kind: "invalid" + repository: string + message: string + } + +type State = { + references: Resolved[] + materializeAll: Effect.Effect + materializeByPath: { path: string; run: Effect.Effect }[] +} + +export interface Interface { + readonly init: () => Effect.Effect + readonly list: () => Effect.Effect + readonly get: (name: string) => Effect.Effect + readonly ensure: (target?: string) => Effect.Effect + readonly contains: (target?: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Reference") {} + +export function referencePath(input: { directory: string; worktree: string; value: string }) { + if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2)) + return path.isAbsolute(input.value) + ? input.value + : path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value) +} + +function resolveGit( + input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined }, +): Resolved { + const parsed = parseRepositoryReference(input.repository) + if (!parsed || parsed.protocol === "file:") { + return { + name: input.name, + kind: "invalid", + repository: input.repository, + message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", + } + } + return { + name: input.name, + kind: "git", + repository: input.repository, + reference: parsed, + path: repositoryCachePath(parsed), + ...("branch" in input ? { branch: input.branch } : {}), + } +} + +function branchLabel(branch: string | undefined) { + return branch ?? "default branch" +} + +function normalizedTarget(target?: string) { + if (!target) return + return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target +} + +function containsReferencePath(referencePath: string, target: string) { + return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target) +} + +export function resolve(input: { + name: string + reference: ReferenceEntry + directory: string + worktree: string +}): Resolved { + if (typeof input.reference === "string") { + if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) { + return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) } + } + return resolveGit({ name: input.name, repository: input.reference }) + } + + if ("path" in input.reference) { + return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) } + } + + return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch }) +} + +export function resolveAll(input: { + references: NonNullable + directory: string + worktree: string +}) { + const seen = new Map() + return Object.entries(input.references).map(([name, reference]) => { + const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree }) + if (resolved.kind !== "git") return resolved + + const existing = seen.get(resolved.path) + if (!existing) { + seen.set(resolved.path, { name, branch: resolved.branch }) + return resolved + } + if (existing.branch === resolved.branch) return resolved + + return { + name, + kind: "invalid" as const, + repository: resolved.repository, + message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`, + } + }) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + const scope = yield* Scope.Scope + + const state = yield* InstanceState.make( + Effect.fn("Reference.state")(function* (ctx) { + const cfg = yield* config.get() + const references = resolveAll({ + references: cfg.reference ?? {}, + directory: ctx.directory, + worktree: ctx.worktree, + }) + const seenPath = new Set() + const gitReferences = references.filter((reference): reference is Extract => { + if (reference.kind !== "git") return false + if (seenPath.has(reference.path)) return false + seenPath.add(reference.path) + return true + }) + const materializeByPath = yield* Effect.forEach( + gitReferences, + Effect.fnUntraced(function* (reference) { + const run = yield* Effect.cached( + RepositoryCache.ensure( + { reference: reference.reference, branch: reference.branch, refresh: true }, + { fs, git }, + ).pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }), + ), + ), + ) + return { path: reference.path, run } + }), + { concurrency: "unbounded" }, + ) + + const materializeAll = yield* Effect.cached( + Flag.KILO_EXPERIMENTAL_SCOUT + ? Effect.gen(function* () { + yield* Effect.forEach( + materializeByPath, + Effect.fnUntraced(function* (item) { + yield* item.run + }), + { concurrency: 4, discard: true }, + ) + }) + : Effect.void, + ) + + return { references, materializeAll, materializeByPath } + }), + ) + + return Service.of({ + init: Effect.fn("Reference.init")(function* () { + if (!Flag.KILO_EXPERIMENTAL_SCOUT) return + yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid) + }), + list: Effect.fn("Reference.list")(function* () { + return yield* InstanceState.use(state, (s) => s.references) + }), + get: Effect.fn("Reference.get")(function* (name: string) { + return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name)) + }), + ensure: Effect.fn("Reference.ensure")(function* (target?: string) { + if (!Flag.KILO_EXPERIMENTAL_SCOUT) return + const full = normalizedTarget(target) + if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll) + return yield* InstanceState.useEffect( + state, + (s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void, + ) + }), + contains: Effect.fn("Reference.contains")(function* (target?: string) { + if (!Flag.KILO_EXPERIMENTAL_SCOUT) return false + const full = normalizedTarget(target) + if (!full) return false + return yield* InstanceState.use(state, (s) => + s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)), + ) + }), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Git.defaultLayer), +) + +export * as Reference from "./reference" diff --git a/packages/opencode/src/reference/repository-cache.ts b/packages/opencode/src/reference/repository-cache.ts new file mode 100644 index 000000000000..d31db8ab5f3f --- /dev/null +++ b/packages/opencode/src/reference/repository-cache.ts @@ -0,0 +1,147 @@ +import path from "path" +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flock } from "@opencode-ai/core/util/flock" +import { Git } from "@/git" +import { + repositoryCachePath, + sameRepositoryReference, + parseRepositoryReference, + validateRepositoryBranch, + type Reference as RepositoryReference, +} from "@/util/repository" + +export type Result = { + repository: string + host: string + remote: string + localPath: string + status: "cached" | "cloned" | "refreshed" + head?: string + branch?: string +} + +function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { + if (!input.reuse) return "cloned" as const + if (input.branchMatches === false) return "refreshed" as const + if (input.refresh) return "refreshed" as const + return "cached" as const +} + +function resetTarget(input: { + requestedBranch?: string + remoteHead: { code: number; stdout: string } + branch: { code: number; stdout: string } +}) { + if (input.requestedBranch) return `origin/${input.requestedBranch}` + if (input.remoteHead.code === 0 && input.remoteHead.stdout) { + return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") + } + if (input.branch.code === 0 && input.branch.stdout) { + return `origin/${input.branch.stdout}` + } + return "HEAD" +} + +export const ensure = Effect.fn("RepositoryCache.ensure")(function* ( + input: { + reference: RepositoryReference + refresh?: boolean + branch?: string + }, + services: { + fs: AppFileSystem.Interface + git: Git.Interface + }, +) { + if (input.branch) validateRepositoryBranch(input.branch) + + const repository = input.reference.label + const remote = input.reference.remote + const localPath = repositoryCachePath(input.reference) + const cloneTarget = parseRepositoryReference(remote) ?? input.reference + + return yield* Effect.acquireUseRelease( + Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })), + () => + Effect.gen(function* () { + yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie) + + const exists = yield* services.fs.existsSafe(localPath) + const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git")) + const origin = hasGitDir + ? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) + : undefined + const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined + const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) + if (exists && !reuse) { + yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie) + } + + const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined + const status = statusForRepository({ + reuse, + refresh: input.refresh, + branchMatches: input.branch ? currentBranch === input.branch : undefined, + }) + + if (status === "cloned") { + const clone = yield* services.git.run( + ["clone", "--depth", "100", ...(input.branch ? ["--branch", input.branch] : []), "--", remote, localPath], + { cwd: path.dirname(localPath) }, + ) + if (clone.exitCode !== 0) { + throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`) + } + } + + if (status === "refreshed") { + const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath }) + if (fetch.exitCode !== 0) { + throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`) + } + + if (input.branch) { + const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], { + cwd: localPath, + }) + if (checkout.exitCode !== 0) { + throw new Error( + checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`, + ) + } + } + + const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) + const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) + const target = resetTarget({ + requestedBranch: input.branch, + remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, + branch: { code: branch.exitCode, stdout: branch.text().trim() }, + }) + + const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath }) + if (reset.exitCode !== 0) { + throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`) + } + } + + const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath }) + const branch = yield* services.git.branch(localPath) + const headText = head.exitCode === 0 ? head.text().trim() : undefined + + return { + repository, + host: input.reference.host, + remote, + localPath, + status, + head: headText, + branch, + } satisfies Result + }), + (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), + ) +}) + +export * as RepositoryCache from "./repository-cache" diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index e12b775830a8..52d50688e3d9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -36,6 +36,7 @@ import { SuggestionApi } from "@/kilocode/server/httpapi/groups/suggestion" import { TelemetryApi } from "@/kilocode/server/httpapi/groups/telemetry" // kilocode_change end import { Authorization } from "./middleware/authorization" +import { SchemaErrorMiddleware } from "./middleware/schema-error" // SSE event schemas built from the BusEvent/SyncEvent registries. const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) @@ -44,6 +45,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads() export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) .addHttpApi(GlobalApi) + .middleware(SchemaErrorMiddleware) .middleware(Authorization) export const InstanceHttpApi = HttpApi.make("opencode-instance") @@ -76,7 +78,8 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(SessionImportApi) .addHttpApi(SuggestionApi) .addHttpApi(TelemetryApi) -// kilocode_change end + // kilocode_change end + .middleware(SchemaErrorMiddleware) export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(RootHttpApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts index d5b10d180037..c780f5222c6d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts @@ -6,3 +6,7 @@ export const QueryBoolean = Schema.Literals(["true", "false"]).pipe( encode: SchemaGetter.transform((value) => (value ? "true" : "false")), }), ) + +export const QueryBooleanOpenApi = { + anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts index 5d44953658a5..b17465417628 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts @@ -1,11 +1,16 @@ import { AllowEverythingPermission } from "@/kilocode/permission/allow-everything" // kilocode_change import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" +// kilocode_change start +import { SessionID } from "@/session/schema" import { Effect, Schema } from "effect" +// kilocode_change end import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { notFound } from "../errors" // kilocode_change -import { AllowEverythingBody, SaveAlwaysRulesBody } from "../groups/permission" // kilocode_change +// kilocode_change start +import { notFound } from "../errors" +import { AllowEverythingBody, SaveAlwaysRulesBody } from "../groups/permission" +// kilocode_change end export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) => Effect.gen(function* () { @@ -20,6 +25,7 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss payload: Permission.ReplyBody }) { const ok = yield* svc.reply({ + // kilocode_change requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message, @@ -41,20 +47,22 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss if (!ok) return yield* notFound(`Permission request not found: ${ctx.params.requestID}`) return true }) - // kilocode_change end - // kilocode_change start const allowEverything = Effect.fn("PermissionHttpApi.allowEverything")(function* (ctx: { payload: Schema.Schema.Type }) { - return yield* AllowEverythingPermission.effect(ctx.payload) + return yield* AllowEverythingPermission.effect({ + enable: ctx.payload.enable, + requestID: ctx.payload.requestID ? PermissionID.make(ctx.payload.requestID) : undefined, + sessionID: ctx.payload.sessionID ? SessionID.make(ctx.payload.sessionID) : undefined, + }) }) - // kilocode_change end return handlers .handle("list", list) .handle("reply", reply) .handle("saveAlwaysRules", saveAlwaysRules) - .handle("allowEverything", allowEverything) // kilocode_change + .handle("allowEverything", allowEverything) + // kilocode_change end }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 03653266caf3..3673571f45fe 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -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" @@ -37,7 +38,7 @@ import { ShellPayload, SummarizePayload, UpdatePayload, - ViewedPayload, + ViewedPayload, // kilocode_change } from "../groups/session" import * as SessionError from "./session-errors" @@ -207,13 +208,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof InitPayload.Type }) { - yield* promptSvc.command({ - sessionID: ctx.params.sessionID, - messageID: ctx.payload.messageID, - model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, - command: Command.Default.INIT, - arguments: "", - }) + yield* promptSvc + .command({ + sessionID: ctx.params.sessionID, + messageID: ctx.payload.messageID, + model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, + command: Command.Default.INIT, + arguments: "", + }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) return true }) @@ -262,18 +265,27 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) { const instance = yield* InstanceState.context const workspace = yield* InstanceState.workspaceID - return HttpServerResponse.stream( - Stream.fromEffect( - promptSvc - // kilocode_change - cast to bridge schema-readonly→PromptInput-mutable; matches legacy Hono session.ts - .prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput) - .pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)), - ).pipe( - Stream.map((message) => JSON.stringify(message)), - Stream.encodeText, - ), - { contentType: "application/json" }, - ) + const message = yield* promptSvc + .prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput) // kilocode_change + .pipe( + Effect.provideService(InstanceRef, instance), + Effect.provideService(WorkspaceRef, workspace), + // 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", + }) }) const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: { @@ -303,7 +315,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof CommandPayload.Type }) { - return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID }) + return yield* promptSvc + .command({ ...ctx.payload, sessionID: ctx.params.sessionID }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts new file mode 100644 index 000000000000..e7d661c5a8ef --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts @@ -0,0 +1,30 @@ +import { Effect } from "effect" +import { HttpServerResponse } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "server" }) + +// Effect's Issue formatter recursively dumps the rejected `actual` value with +// no truncation, so a 5KB invalid array produces a ~360KB string. Cap to keep +// 4xx responses small and avoid mirroring entire request payloads (which may +// contain secrets) into the response body and log file. +const REASON_LIMIT = 1024 +function truncateReason(reason: string) { + if (reason.length <= REASON_LIMIT) return reason + return reason.slice(0, REASON_LIMIT) + `… (${reason.length - REASON_LIMIT} more chars)` +} + +// Default Respondable returns an empty 400 body. Match the NamedError shape +// used by other 4xx/5xx so the SDK's `wrapClientError` extracts `.data.message`. +export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiSchemaError", +) {} + +export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { + const reason = truncateReason(error.cause.message) + log.warn("schema rejection", { kind: error.kind, reason }) + return Effect.succeed( + HttpServerResponse.jsonUnsafe({ name: "BadRequest", data: { message: reason, kind: error.kind } }, { status: 400 }), + ) +}) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index dfd30bafd507..c8e5e1cd8bef 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -1,7 +1,10 @@ import { OpenApi } from "effect/unstable/httpapi" -import { matchLegacyKiloOpenApi } from "@/kilocode/server/httpapi/public" // kilocode_change -import * as KiloServer from "@/kilocode/server/server" // kilocode_change +// kilocode_change start +import { matchLegacyKiloOpenApi } from "@/kilocode/server/httpapi/public" +import * as KiloServer from "@/kilocode/server/server" +// kilocode_change end import { OpenCodeHttpApi } from "./api" +import { QueryBooleanOpenApi } from "./groups/query" type OpenApiParameter = { name: string @@ -56,24 +59,29 @@ type OpenApiResponse = { // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. -const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryParameterSchemas: Record = { "GET /experimental/session start": { type: "number" }, + "GET /experimental/session roots": QueryBooleanOpenApi, + "GET /experimental/session archived": QueryBooleanOpenApi, "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, - "GET /experimental/session worktrees": { type: "boolean" }, // kilocode_change - "GET /kilo/cloud-sessions cursor": { type: "string" }, // kilocode_change - "GET /kilo/cloud-sessions limit": { type: "number" }, // kilocode_change + // kilocode_change start + "GET /experimental/session worktrees": { type: "boolean" }, + "GET /kilo/cloud-sessions cursor": { type: "string" }, + "GET /kilo/cloud-sessions limit": { type: "number" }, + // kilocode_change end "GET /experimental/session cursor": { type: "number" }, "GET /experimental/session limit": { type: "number" }, "GET /session start": { type: "number" }, + "GET /session roots": QueryBooleanOpenApi, "GET /session limit": { type: "number" }, - "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, "GET /api/session limit": { type: "number" }, "GET /api/session start": { type: "number" }, + "GET /api/session roots": QueryBooleanOpenApi, "GET /api/session/{sessionID}/message limit": { type: "number" }, } +// kilocode_change start const PathParameterSchemas: Record = { sessionID: { type: "string", pattern: "^ses.*" }, messageID: { type: "string", pattern: "^msg.*" }, @@ -81,6 +89,7 @@ const PathParameterSchemas: Record = { permissionID: { type: "string", pattern: "^per.*" }, ptyID: { type: "string", pattern: "^pty.*" }, } +// kilocode_change end const LegacyComponentDescriptions: Record = { LogLevel: "Log level", @@ -189,17 +198,20 @@ function addLegacyErrorSchemas(spec: OpenApiSpec) { if (!spec.components?.schemas) return spec.components.schemas.BadRequestError = { type: "object", - required: ["data", "errors", "success"], + required: ["name", "data"], properties: { - data: {}, - errors: { - type: "array", - items: { - type: "object", - additionalProperties: {}, + name: { type: "string", enum: ["BadRequest"] }, + data: { + type: "object", + required: ["message"], + properties: { + message: { type: "string" }, + kind: { + type: "string", + enum: ["Params", "Headers", "Query", "Body", "Payload"], + }, }, }, - success: { type: "boolean", enum: [false] }, }, } spec.components.schemas.NotFoundError = { @@ -497,7 +509,7 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | function normalizeParameter(param: OpenApiParameter, route: string) { if (!param.schema || typeof param.schema !== "object") return if (param.in === "path") { - param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema) + param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema) // kilocode_change return } if (param.in === "query") { @@ -506,27 +518,23 @@ function normalizeParameter(param: OpenApiParameter, route: string) { param.schema = override return } - if (QueryBooleanParameters.has(param.name)) { - param.schema = { - anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], - } - return - } } param.schema = stripOptionalNull(param.schema) } +// kilocode_change start function pathParameterSchema(route: string, name: string) { if (name in PathParameterSchemas) return PathParameterSchemas[name] if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } - if (name === "processID" && route.includes(" /background-process/")) return { type: "string", pattern: "^bgp.*" } // kilocode_change + if (name === "processID" && route.includes(" /background-process/")) return { type: "string", pattern: "^bgp.*" } if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" } - // /network/* reuses QuestionID (prefix "que"), not a separate brand. // kilocode_change - if (name === "requestID" && route.startsWith("POST /network/")) return { type: "string", pattern: "^que.*" } // kilocode_change + // /network/* reuses QuestionID (prefix "que"), not a separate brand. + if (name === "requestID" && route.startsWith("POST /network/")) return { type: "string", pattern: "^que.*" } return undefined } +// kilocode_change end export const PublicApi = OpenCodeHttpApi.annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index bcb3ce6241a4..78a3b6bdbb5b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -88,6 +88,7 @@ import { compressionLayer } from "./middleware/compression" import { corsVaryFix } from "./middleware/cors-vary" import { errorLayer } from "./middleware/error" import { fenceLayer } from "./middleware/fence" +import { schemaErrorLayer } from "./middleware/schema-error" export const context = Context.makeUnsafe(new Map()) @@ -179,6 +180,7 @@ const uiRoute = HttpRouter.use((router) => export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe( + Layer.provide(schemaErrorLayer), // kilocode_change Layer.provide([ errorLayer, compressionLayer, diff --git a/packages/opencode/src/server/shared/workspace-routing.ts b/packages/opencode/src/server/shared/workspace-routing.ts index 366c455dd6bb..f8e9d6d15cb4 100644 --- a/packages/opencode/src/server/shared/workspace-routing.ts +++ b/packages/opencode/src/server/shared/workspace-routing.ts @@ -19,6 +19,7 @@ export function isLocalWorkspaceRoute(method: string, path: string) { export function getWorkspaceRouteSessionID(url: URL) { if (url.pathname === "/session/status") return null + if (url.pathname === "/session/viewed") return null // kilocode_change - Kilo static route is not a session ID const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] if (!id) return null diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index acc79442e744..0757acd6a47c 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -4,7 +4,6 @@ import * as Session from "./session" import { SessionID, MessageID, PartID } from "./schema" import { Provider } from "@/provider/provider" import { MessageV2 } from "./message-v2" -import z from "zod" import { Token } from "@/util/token" import * as Log from "@opencode-ai/core/util/log" import { SessionProcessor } from "./processor" @@ -18,14 +17,17 @@ import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" -import { fn } from "@/util/fn" -import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change -import { KiloCompactionPayloadRecovery } from "@/kilocode/session/compaction-payload-recovery" // kilocode_change -import { KiloCompactionChunks } from "@/kilocode/session/compaction-chunks" // kilocode_change -import { SessionExport } from "@/kilocode/session-export" // kilocode_change -import { KiloSession } from "@/kilocode/session" // kilocode_change -import { EventV2 } from "@/v2/event" +import { serviceUse } from "@/effect/service-use" +// kilocode_change start +import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" +import { KiloCompactionPayloadRecovery } from "@/kilocode/session/compaction-payload-recovery" +import { KiloCompactionChunks } from "@/kilocode/session/compaction-chunks" +import { SessionExport } from "@/kilocode/session-export" +import { KiloSession } from "@/kilocode/session" +// kilocode_change end +import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" +import { Flag } from "@opencode-ai/core/flag/flag" const log = Log.create({ service: "session.compaction" }) @@ -217,6 +219,8 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionCompaction") {} +export const use = serviceUse(Service) + export const layer: Layer.Layer< Service, never, @@ -227,6 +231,7 @@ export const layer: Layer.Layer< | Plugin.Service | SessionProcessor.Service | Provider.Service + | SyncEvent.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -237,6 +242,7 @@ export const layer: Layer.Layer< const plugin = yield* Plugin.Service const processors = yield* SessionProcessor.Service const provider = yield* Provider.Service + const sync = yield* SyncEvent.Service const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: { tokens: MessageV2.Assistant["tokens"] @@ -476,9 +482,7 @@ export const layer: Layer.Layer< updateMessage: session.updateMessage, updatePart: session.updatePart, }) - // kilocode_change end - // kilocode_change start - fallback to chunked compaction when the first summary overflows const fallback = KiloCompactionChunks.eligible({ result, error: processor.message.error ?? processor.compactError?.(), @@ -630,12 +634,14 @@ export const layer: Layer.Layer< parts: [], }, ) - EventV2.run(SessionEvent.Compaction.Ended.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - text: summary ?? "", - include: selected.tail_start_id, - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Compaction.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + text: summary ?? "", + 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) @@ -699,11 +705,13 @@ export const layer: Layer.Layer< // kilocode_change start - keep auto-compaction markers visible during queued turns KiloSessionPromptQueue.retarget(input.sessionID, msg.id) // kilocode_change end - EventV2.run(SessionEvent.Compaction.Started.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - reason: input.auto ? "auto" : "manual", - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Compaction.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + reason: input.auto ? "auto" : "manual", + }) + } }) return Service.of({ @@ -724,13 +732,13 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ), ) const { runPromise } = makeRuntime(Service, defaultLayer) export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { - // kilocode_change return runPromise((svc) => svc.isOverflow(input)) } @@ -739,15 +747,4 @@ export async function prune(input: { sessionID: SessionID; reason?: PruneReason return runPromise((svc) => svc.prune(input)) } -export const create = fn( - z.object({ - sessionID: SessionID.zod, - agent: z.string(), - model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }), - auto: z.boolean(), - overflow: z.boolean().optional(), - }), - (input) => runPromise((svc) => svc.create(input)), -) - export * as SessionCompaction from "./compaction" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 9d1a246bf426..e2f70c0973b8 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot" import * as Session from "./session" import { LLM } from "./llm" import { MessageV2 } from "./message-v2" +import { Image } from "@/image/image" import { isOverflow } from "./overflow" import { PartID } from "./schema" import type { SessionID } from "./schema" @@ -17,17 +18,20 @@ import { SessionStatus } from "./status" import { SessionSummary } from "./summary" import type { Provider } from "@/provider/provider" import { Question } from "@/question" -import { KiloSessionProcessor, type ReviewTelemetry } from "@/kilocode/session/processor" // kilocode_change -import { KiloSessionOverflow } from "@/kilocode/session/overflow" // kilocode_change -import { Suggestion } from "@/kilocode/suggestion" // kilocode_change -import { NotFoundError } from "@/storage/storage" // kilocode_change +// kilocode_change start +import { KiloSessionProcessor, type ReviewTelemetry } from "@/kilocode/session/processor" +import { KiloSessionOverflow } from "@/kilocode/session/overflow" +import { Suggestion } from "@/kilocode/suggestion" +import { NotFoundError } from "@/storage/storage" +// kilocode_change end import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" -import { EventV2 } from "@/v2/event" +import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" import { Modelv2 } from "@/v2/model" import * as DateTime from "effect/DateTime" +import { Flag } from "@opencode-ai/core/flag/flag" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -59,8 +63,10 @@ type Input = { assistantMessage: MessageV2.Assistant sessionID: SessionID model: Provider.Model - telemetry?: ReviewTelemetry // kilocode_change - snapshotInitialization?: "wait" // kilocode_change - managed clients wait silently for slow baseline creation + // kilocode_change start + telemetry?: ReviewTelemetry + snapshotInitialization?: "wait" + // kilocode_change end } export interface Interface { @@ -83,8 +89,10 @@ interface ProcessorContext extends Input { compactionError: ReturnType | undefined // kilocode_change currentText: MessageV2.TextPart | undefined reasoningMap: Record - stepStart: number // kilocode_change - step: { reasoning: boolean; text: boolean; tool: boolean } // kilocode_change + // kilocode_change start + stepStart: number + step: { reasoning: boolean; text: boolean; tool: boolean } + // kilocode_change end } type StreamEvent = Event @@ -102,8 +110,10 @@ export const layer: Layer.Layer< | LLM.Service | Permission.Service | Plugin.Service + | Image.Service | SessionSummary.Service | SessionStatus.Service + | SyncEvent.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -118,6 +128,8 @@ export const layer: Layer.Layer< const summary = yield* SessionSummary.Service const scope = yield* Scope.Scope const status = yield* SessionStatus.Service + const image = yield* Image.Service + const sync = yield* SyncEvent.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -142,9 +154,11 @@ export const layer: Layer.Layer< compactionError: undefined, // kilocode_change currentText: undefined, reasoningMap: {}, - telemetry: input.telemetry, // kilocode_change - stepStart: 0, // kilocode_change - step: { reasoning: false, text: false, tool: false }, // kilocode_change + // kilocode_change start + telemetry: input.telemetry, + stepStart: 0, + step: { reasoning: false, text: false, tool: false }, + // kilocode_change end } let aborted = false const ac = new AbortController() // kilocode_change — abort controller for offline handler @@ -276,11 +290,13 @@ export const layer: Layer.Layer< if (value.id in ctx.reasoningMap) return ctx.step.reasoning = true // kilocode_change // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Reasoning.Started.Sync, { - sessionID: ctx.sessionID, - reasoningID: value.id, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Reasoning.Started.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.reasoningMap[value.id] = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -309,12 +325,14 @@ export const layer: Layer.Layer< case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Reasoning.Ended.Sync, { - sessionID: ctx.sessionID, - reasoningID: value.id, - text: ctx.reasoningMap[value.id].text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Reasoning.Ended.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + text: ctx.reasoningMap[value.id].text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } @@ -329,12 +347,14 @@ export const layer: Layer.Layer< } ctx.step.tool = true // kilocode_change // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Input.Started.Sync, { - sessionID: ctx.sessionID, - callID: value.id, - name: value.toolName, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Input.Started.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + name: value.toolName, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } const part = yield* session.updatePart({ id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -358,12 +378,14 @@ export const layer: Layer.Layer< case "tool-input-end": { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Input.Ended.Sync, { - sessionID: ctx.sessionID, - callID: value.id, - text: "", - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Input.Ended.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + text: "", + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } return } @@ -371,8 +393,8 @@ export const layer: Layer.Layer< if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } - ctx.step.tool = true // kilocode_change - // kilocode_change start — create tool part if tool-input-start was never emitted + // kilocode_change start + ctx.step.tool = true if (!ctx.toolcalls[value.toolCallId]) { log.warn("tool-call without prior tool-input-start", { toolCallId: value.toolCallId, @@ -397,17 +419,19 @@ export const layer: Layer.Layer< // kilocode_change end const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Called.Sync, { - sessionID: ctx.sessionID, - callID: value.toolCallId, - tool: value.toolName, - input: value.input, - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Called.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + tool: value.toolName, + input: value.input, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, @@ -452,30 +476,56 @@ export const layer: Layer.Layer< case "tool-result": { const toolCall = yield* readToolCall(value.toolCallId) + const toolAttachments: MessageV2.FilePart[] = ( + Array.isArray(value.output.attachments) ? value.output.attachments : [] + ).filter( + (attachment: unknown): attachment is MessageV2.FilePart => + isRecord(attachment) && + attachment.type === "file" && + typeof attachment.mime === "string" && + typeof attachment.url === "string", + ) + const normalized = yield* Effect.forEach(toolAttachments, (attachment) => + attachment.mime.startsWith("image/") + ? image.normalize(attachment).pipe(Effect.exit) + : Effect.succeed(Exit.succeed(attachment)), + ) + const omitted = normalized.filter(Exit.isFailure).length + const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value) + const output = { + ...value.output, + output: + omitted === 0 + ? value.output.output + : `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`, + attachments: attachments?.length ? attachments : undefined, + } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Success.Sync, { - sessionID: ctx.sessionID, - callID: value.toolCallId, - structured: value.output.metadata, - content: [ - { - type: "text", - text: value.output.output, + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Success.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + structured: output.metadata, + content: [ + { + type: "text", + text: output.output, + }, + ...(output.attachments?.map((item: MessageV2.FilePart) => ({ + type: "file", + uri: item.url, + mime: item.mime, + name: item.filename, + })) ?? []), + ], + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, }, - ...(value.output.attachments?.map((item: MessageV2.FilePart) => ({ - type: "file", - uri: item.url, - mime: item.mime, - name: item.filename, - })) ?? []), - ], - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - yield* completeToolCall(value.toolCallId, value.output) - // kilocode_change start + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } + yield* completeToolCall(value.toolCallId, output) + // kilocode_change start - dismissed suggestions stop the turn after persisting normalized output if (value.output.metadata?.dismissed === true) { ctx.blocked = ctx.shouldBreak } @@ -486,18 +536,20 @@ export const layer: Layer.Layer< case "tool-error": { const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Failed.Sync, { - sessionID: ctx.sessionID, - callID: value.toolCallId, - error: { - type: "unknown", - message: errorMessage(value.error), - }, - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Failed.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + error: { + type: "unknown", + message: errorMessage(value.error), + }, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* failToolCall(value.toolCallId, value.error) return } @@ -506,9 +558,9 @@ export const layer: Layer.Layer< throw value.error case "start-step": - ctx.stepStart = performance.now() // kilocode_change - ctx.step = { reasoning: false, text: false, tool: false } // kilocode_change - // kilocode_change start - pass turn context for slow-snapshot UI/policy handling + // kilocode_change start + ctx.stepStart = performance.now() + ctx.step = { reasoning: false, text: false, tool: false } if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track({ sessionID: ctx.sessionID, @@ -518,17 +570,19 @@ export const layer: Layer.Layer< // kilocode_change end if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Step.Started.Sync, { - sessionID: ctx.sessionID, - agent: input.assistantMessage.agent, - model: { - id: Modelv2.ID.make(ctx.model.id), - providerID: Modelv2.ProviderID.make(ctx.model.providerID), - variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), - }, - snapshot: ctx.snapshot, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Step.Started.Sync, { + sessionID: ctx.sessionID, + agent: input.assistantMessage.agent, + model: { + id: Modelv2.ID.make(ctx.model.id), + providerID: Modelv2.ProviderID.make(ctx.model.providerID), + variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), + }, + snapshot: ctx.snapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } yield* session.updatePart({ id: PartID.ascending(), @@ -566,14 +620,16 @@ export const layer: Layer.Layer< // kilocode_change end if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Step.Ended.Sync, { - sessionID: ctx.sessionID, - finish: value.finishReason, - cost: usage.cost, - tokens: usage.tokens, - snapshot: completedSnapshot, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Step.Ended.Sync, { + sessionID: ctx.sessionID, + finish: value.finishReason, + cost: usage.cost, + tokens: usage.tokens, + snapshot: completedSnapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } ctx.assistantMessage.finish = value.finishReason // kilocode_change start - capture any subagent cost propagated by tool calls during this step (#6321) @@ -650,10 +706,12 @@ export const layer: Layer.Layer< case "text-start": if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Text.Started.Sync, { - sessionID: ctx.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Text.Started.Sync, { + sessionID: ctx.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } ctx.currentText = { id: PartID.ascending(), @@ -697,11 +755,13 @@ export const layer: Layer.Layer< if (ctx.currentText.text.trim()) ctx.step.text = true // kilocode_change if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Text.Ended.Sync, { - sessionID: ctx.sessionID, - text: ctx.currentText.text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Text.Ended.Sync, { + sessionID: ctx.sessionID, + text: ctx.currentText.text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } { const end = Date.now() @@ -804,14 +864,16 @@ export const layer: Layer.Layer< } if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Step.Failed.Sync, { - sessionID: ctx.sessionID, - error: { - type: "unknown", - message: errorMessage(e), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Step.Failed.Sync, { + sessionID: ctx.sessionID, + error: { + type: "unknown", + message: errorMessage(e), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } ctx.assistantMessage.error = error yield* bus.publish(Session.Event.Error, { @@ -873,22 +935,28 @@ export const layer: Layer.Layer< // kilocode_change end set: (info) => { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Retried.Sync, { - sessionID: ctx.sessionID, - attempt: info.attempt, - error: { - message: info.message, - isRetryable: true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - return status.set(ctx.sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - action: info.action, - next: info.next, - }) + const event = Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM + ? sync.run(SessionEvent.Retried.Sync, { + sessionID: ctx.sessionID, + attempt: info.attempt, + error: { + message: info.message, + isRetryable: true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + : Effect.void + return event.pipe( + Effect.andThen( + status.set(ctx.sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + action: info.action, + next: info.next, + }), + ), + ) }, }), ), @@ -898,7 +966,7 @@ export const layer: Layer.Layer< if (ctx.needsCompaction) return "compact" if (ctx.blocked || ctx.assistantMessage.error) return "stop" - return "continue" // kilocode_change - remove once compactError is no longer Kilo-specific + return "continue" }) }) @@ -927,8 +995,10 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionStatus.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f383a3151165..628fc16bd062 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -61,7 +61,7 @@ import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" -import { EventV2 } from "@/v2/event" +import { SyncEvent } from "@/sync" // kilocode_change - preserve Kilo v2 event dual-write wiring import { SessionEvent } from "@/v2/session-event" import { Modelv2 } from "@/v2/model" import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" @@ -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,8 @@ 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() }) @@ -806,7 +809,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } - const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID)) + const model = input.model ?? agent.model ?? (yield* currentModel(input.sessionID)) // kilocode_change const userMsg: MessageV2.User = { id: input.messageID ?? MessageID.ascending(), sessionID: input.sessionID, @@ -841,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", @@ -849,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 }, @@ -857,12 +860,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, } yield* sessions.updatePart(part) - EventV2.run(SessionEvent.Shell.Started.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(started), - callID, - command: input.command, - }) + // kilocode_change start - preserve Kilo v2 shell event dual-write + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Shell.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(started), + callID, + command: input.command, + }) + } + // kilocode_change end return { msg, part, cwd: ctx.directory } }).pipe(Effect.ensuring(markReady)) @@ -878,12 +885,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the output += "\n\n" + ["", "User aborted the command", ""].join("\n") } const completed = Date.now() - EventV2.run(SessionEvent.Shell.Ended.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(completed), - callID: part.callID, - output, - }) + // kilocode_change start - preserve Kilo v2 shell event dual-write + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Shell.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(completed), + callID: part.callID, + output, + }) + } + // kilocode_change end if (!msg.time.completed) { msg.time.completed = completed yield* sessions.updateMessage(msg) @@ -964,11 +975,23 @@ NOTE: At any point in time through this workflow you should feel free to ask the return yield* Effect.failCause(exit.cause) }) - const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) { + // kilocode_change start - preserve persisted per-session model selection + const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) { + const current = Database.use((db) => + db.select({ model: SessionTable.model }).from(SessionTable).where(eq(SessionTable.id, sessionID)).get(), + ) + if (current?.model) { + return { + providerID: ProviderID.make(current.model.providerID), + modelID: ModelID.make(current.model.id), + ...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}), + } + } const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model) if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model return yield* provider.defaultModel() }) + // kilocode_change end const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) { const agentName = input.agent || (yield* agents.defaultAgent()) @@ -981,7 +1004,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw error } - const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID)) + const current = Database.use((db) => + db + .select({ agent: SessionTable.agent, model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID)) // kilocode_change const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID const full = !input.variant && ag.variant && same @@ -1006,15 +1036,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the editorContext: input.editorContext, // kilocode_change } - const current = Database.use((db) => - db - .select({ agent: SessionTable.agent, model: SessionTable.model }) - .from(SessionTable) - .where(eq(SessionTable.id, input.sessionID)) - .get(), - ) if (current?.agent !== info.agent) { - EventV2.run(SessionEvent.AgentSwitched.Sync, { + yield* sync.run(SessionEvent.AgentSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), agent: info.agent, @@ -1023,9 +1046,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the if ( current?.model?.providerID !== info.model.providerID || current.model.id !== info.model.modelID || - current.model.variant !== info.model.variant + (current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant ) { - EventV2.run(SessionEvent.ModelSwitched.Sync, { + yield* sync.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), model: { @@ -1122,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 }) @@ -1264,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, @@ -1272,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, ] } } @@ -1310,7 +1366,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the return [{ ...part, messageID: info.id, sessionID: input.sessionID }] }) - const parts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( + // kilocode_change start - resolve and persist the exact transformed Kilo prompt parts + const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( Effect.map((x) => x.flat().map(assign)), ) @@ -1323,9 +1380,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the messageID: input.messageID, variant: input.variant, }, - { message: info, parts }, + { message: info, parts: resolvedParts }, ) + const parts = resolvedParts + // kilocode_change end + const parsed = MessageV2.Info.zod.safeParse(info) if (!parsed.success) { log.error("invalid user message before save", { @@ -1397,24 +1457,30 @@ NOTE: At any point in time through this workflow you should feel free to ask the synthetic: [] as string[], }, ) + // kilocode_change start - preserve Kilo v2 prompt event dual-write // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Prompted.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(info.time.created), - prompt: { - text: nextPrompt.text.join("\n"), - files: nextPrompt.files, - agents: nextPrompt.agents, - }, - }) - for (const text of nextPrompt.synthetic) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Synthetic.Sync, { + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Prompted.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), - text, + prompt: { + text: nextPrompt.text.join("\n"), + files: nextPrompt.files, + agents: nextPrompt.agents, + }, }) } + for (const text of nextPrompt.synthetic) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Synthetic.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + text, + }) + } + } + // kilocode_change end return { info, parts } }, Effect.scoped) @@ -1423,9 +1489,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) yield* revert.cleanup(session) - // kilocode_change start - persist queued prompts immediately while serializing each follow-up loop + // kilocode_change start - recover interrupted Kilo turns before accepting a follow-up yield* KiloSessionPrompt.recoverDanglingAssistant({ sessionID: input.sessionID, status, sessions }) yield* KiloSessionPrompt.recoverProviderFinishError({ sessionID: input.sessionID, status, sessions }) + // kilocode_change end const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) @@ -1946,7 +2013,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (cmdAgent?.model) return cmdAgent.model } if (input.model) return Provider.parseModel(input.model) - return yield* lastModel(input.sessionID) + return yield* currentModel(input.sessionID) // kilocode_change }) yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) @@ -1980,7 +2047,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const userModel = isSubtask ? input.model ? Provider.parseModel(input.model) - : yield* lastModel(input.sessionID) + : yield* currentModel(input.sessionID) // kilocode_change : taskModel yield* plugin.trigger( @@ -2031,6 +2098,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), @@ -2046,6 +2115,7 @@ export const defaultLayer = Layer.suspend(() => LLM.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer, + SyncEvent.defaultLayer, // kilocode_change - provide Kilo v2 event dual-write service ), ), ), @@ -2085,6 +2155,7 @@ export const PromptInput = Schema.Struct({ ]).annotate({ discriminator: "type" }), ), }).pipe(withStatics((s) => ({ zod: zod(s) }))) +// kilocode_change start - retain precise prompt input types for Kilo callers // `z.discriminatedUnion` erases the discriminated members' shapes back to // `{}` when walked from the generic `z.ZodType` input. Restore the precise // `parts` type from the exported Schema input types so callers see a proper @@ -2098,6 +2169,7 @@ export type PromptInput = Omit, "parts" | parts: PartInputUnion[] editorContext?: MessageV2.EditorContext } +// kilocode_change end export class LoopInput extends Schema.Class("SessionPrompt.LoopInput")({ sessionID: SessionID, diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index d0e6cd4cb7b4..caf8f9d78331 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,34 +1,30 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe( +export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe( Schema.brand("SessionID"), withStatics((s) => ({ descending: (id?: string) => s.make(Identifier.descending("session", id)), - zod: zod(s), })), ) export type SessionID = Schema.Schema.Type -export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("message") }).pipe( +export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( Schema.brand("MessageID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("message", id)), - zod: zod(s), })), ) export type MessageID = Schema.Schema.Type -export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("part") }).pipe( +export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( Schema.brand("PartID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("part", id)), - zod: zod(s), })), ) diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index e4e2e75b73a1..dde2e53d17af 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( +export const EventID = Schema.String.check(Schema.isStartsWith("evt")).pipe( Schema.brand("EventID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("event", id)), diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 6646b882b95d..9065ecc23c4d 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" import * as Tool from "./tool" +import { Reference } from "@/reference/reference" // kilocode_change start — support absolute glob patterns (e.g. ~/.config/kilo/command/*.md) function normalize(p: string) { @@ -38,6 +39,7 @@ export const GlobTool = Tool.define( Effect.gen(function* () { const rg = yield* Ripgrep.Service const fs = yield* AppFileSystem.Service + const reference = yield* Reference.Service return { description: DESCRIPTION, @@ -56,18 +58,25 @@ export const GlobTool = Tool.define( }, }) - const base = absolute?.dir ?? params.path ?? ins.directory // kilocode_change + // kilocode_change start + const base = absolute?.dir ?? params.path ?? ins.directory const search = path.isAbsolute(base) ? base : path.resolve(ins.directory, base) + // kilocode_change end + yield* reference.ensure(search) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) if (info?.type === "File") { throw new Error(`glob path must be a directory: ${search}`) } - yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) + yield* assertExternalDirectoryEffect(ctx, search, { + bypass: yield* reference.contains(search), + kind: "directory", + }) const limit = 100 let truncated = false + // kilocode_change start const files = yield* rg - .files({ cwd: search, glob: [absolute?.pattern ?? params.pattern], signal: ctx.abort }) // kilocode_change + .files({ cwd: search, glob: [absolute?.pattern ?? params.pattern], signal: ctx.abort }) .pipe( Stream.mapEffect((file) => Effect.gen(function* () { @@ -85,6 +94,7 @@ export const GlobTool = Tool.define( Stream.runCollect, Effect.map((chunk) => [...chunk]), ) + // kilocode_change end if (files.length > limit) { truncated = true diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index fb3e70cad25d..4e89198dffdf 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import * as Tool from "./tool" +import { Reference } from "@/reference/reference" const MAX_LINE_LENGTH = 2000 @@ -25,6 +26,7 @@ export const GrepTool = Tool.define( Effect.gen(function* () { const fs = yield* AppFileSystem.Service const rg = yield* Ripgrep.Service + const reference = yield* Reference.Service return { description: DESCRIPTION, @@ -57,10 +59,12 @@ export const GrepTool = Tool.define( ? (params.path ?? ins.directory) : path.join(ins.directory, params.path ?? "."), ) + yield* reference.ensure(search) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) const cwd = info?.type === "Directory" ? search : path.dirname(search) const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)] yield* assertExternalDirectoryEffect(ctx, search, { + bypass: yield* reference.contains(search), kind: info?.type === "Directory" ? "directory" : "file", }) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 01ae0c985cfe..b2e9d54d95b0 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -12,6 +12,7 @@ import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" +import { Reference } from "@/reference/reference" // kilocode_change start import * as Encoding from "../kilocode/encoding" import * as TextStream from "../kilocode/text-stream" @@ -48,6 +49,7 @@ export const ReadTool = Tool.define( const fs = yield* AppFileSystem.Service const instruction = yield* Instruction.Service const lsp = yield* LSP.Service + const reference = yield* Reference.Service const scope = yield* Scope.Scope const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) { @@ -213,6 +215,7 @@ export const ReadTool = Tool.define( if (process.platform === "win32") { filepath = AppFileSystem.normalizePath(filepath) } + yield* reference.ensure(filepath) const title = path.relative(instance.worktree, filepath) const stat = yield* fs.stat(filepath).pipe( @@ -223,7 +226,7 @@ export const ReadTool = Tool.define( ) yield* assertExternalDirectoryEffect(ctx, filepath, { - bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), + bypass: Boolean(ctx.extra?.["bypassCwdCheck"]) || (yield* reference.contains(filepath)), kind: stat?.type === "Directory" ? "directory" : "file", }) @@ -361,8 +364,8 @@ export const ReadTool = Tool.define( // routed through TextStream.withFallback so non-UTF-8 files are decoded via // iconv. The body otherwise matches upstream. export async function lines(filepath: string, opts: { limit: number; offset: number }) { - const extracted = await Extract.open(filepath) // kilocode_change - extract supported document contents before paging - if (extracted) return readLines(extracted, opts) // kilocode_change + const extracted = await Extract.open(filepath) + if (extracted) return readLines(extracted, opts) return TextStream.withFallback(filepath, (stream) => readLines(stream, opts)) } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 5b28aa9d7c31..ce81b706efcc 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,8 +1,10 @@ import { PlanExitTool } from "./plan" import { Session } from "@/session/session" import { QuestionTool } from "./question" -import { SuggestTool } from "../kilocode/suggestion/tool" // kilocode_change -import { Command } from "@/command" // kilocode_change +// kilocode_change start +import { SuggestTool } from "../kilocode/suggestion/tool" +import { Command } from "@/command" +// kilocode_change end import { ShellTool } from "./shell" import { EditTool } from "./edit" import { GlobTool } from "./glob" @@ -54,6 +56,7 @@ import { Git } from "@/git" import { Skill } from "../skill" import { Permission } from "@/permission" import { SessionStatus } from "@/session/status" // kilocode_change +import { Reference } from "@/reference/reference" const log = Log.create({ service: "tool.registry" }) @@ -95,6 +98,7 @@ export const layer: Layer.Layer< | Session.Service | Provider.Service | Git.Service + | Reference.Service | LSP.Service | Instruction.Service | AppFileSystem.Service @@ -104,8 +108,10 @@ export const layer: Layer.Layer< | Ripgrep.Service | Format.Service | Truncate.Service - | Command.Service // kilocode_change - | SessionStatus.Service // kilocode_change + // kilocode_change start + | Command.Service + | SessionStatus.Service + // kilocode_change end > = Layer.effect( Service, Effect.gen(function* () { @@ -245,6 +251,7 @@ export const layer: Layer.Layer< return { custom, + // kilocode_change start builtin: KiloToolRegistry.describe( [ tool.invalid, @@ -262,15 +269,14 @@ export const layer: Layer.Layer< ...(Flag.KILO_EXPERIMENTAL_SCOUT ? [tool.code, tool.repo_clone, tool.repo_overview] : []), tool.skill, tool.patch, - // kilocode_change start tool.plan, ...(["cli", "vscode"].includes(Flag.KILO_CLIENT) ? [tool.suggest] : []), ...KiloToolRegistry.extra(kilo, cfg), - // kilocode_change end ...(Flag.KILO_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), ], kilo, - ), // kilocode_change + ), + // kilocode_change end task: tool.task, read: tool.read, } @@ -373,28 +379,32 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Format.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Truncate.defaultLayer), - Layer.provide(Command.defaultLayer), // kilocode_change - Layer.provide(SessionStatus.defaultLayer), // kilocode_change - ), +export const defaultLayer = Layer.suspend( + () => + layer + .pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Question.defaultLayer), + Layer.provide(Todo.defaultLayer), + Layer.provide(Skill.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Format.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Truncate.defaultLayer), + ) + // kilocode_change start - provide Kilo-owned registry dependencies + .pipe(Layer.provide(Command.defaultLayer), Layer.provide(SessionStatus.defaultLayer)), + // kilocode_change end ) export * as ToolRegistry from "./registry" diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts index 969a3e66dd9b..2b5e41844ebd 100644 --- a/packages/opencode/src/tool/repo_clone.ts +++ b/packages/opencode/src/tool/repo_clone.ts @@ -1,11 +1,10 @@ -import path from "path" import { Effect, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Flock } from "@opencode-ai/core/util/flock" import { Git } from "@/git" import DESCRIPTION from "./repo_clone.txt" import * as Tool from "./tool" -import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository" +import { parseRemoteRepositoryReference, repositoryCachePath, validateRepositoryBranch } from "@/util/repository" +import { RepositoryCache } from "@/reference/repository-cache" export const Parameters = Schema.Struct({ repository: Schema.String.annotate({ @@ -29,36 +28,6 @@ type Metadata = { branch?: string } -function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { - if (!input.reuse) return "cloned" as const - if (input.branchMatches === false) return "refreshed" as const - if (input.refresh) return "refreshed" as const - return "cached" as const -} - -function resetTarget(input: { - requestedBranch?: string - remoteHead: { code: number; stdout: string } - branch: { code: number; stdout: string } -}) { - if (input.requestedBranch) return `origin/${input.requestedBranch}` - if (input.remoteHead.code === 0 && input.remoteHead.stdout) { - return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") - } - if (input.branch.code === 0 && input.branch.stdout) { - return `origin/${input.branch.stdout}` - } - return "HEAD" -} - -function validateBranch(branch: string) { - if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { - throw new Error( - "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", - ) - } -} - export const RepoCloneTool = Tool.define( "repo_clone", Effect.gen(function* () { @@ -70,16 +39,12 @@ export const RepoCloneTool = Tool.define, ctx: Tool.Context) => Effect.gen(function* () { - const reference = parseRepositoryReference(params.repository) - if (!reference) - throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") - if (reference.protocol === "file:") throw new Error("Local file repositories are not supported") - if (params.branch) validateBranch(params.branch) + const reference = parseRemoteRepositoryReference(params.repository) + if (params.branch) validateRepositoryBranch(params.branch) const repository = reference.label const remote = reference.remote const localPath = repositoryCachePath(reference) - const cloneTarget = parseRepositoryReference(remote) ?? reference yield* ctx.ask({ permission: "repo_clone", @@ -94,115 +59,21 @@ export const RepoCloneTool = Tool.define Flock.acquire(`repo-clone:${localPath}`, { signal })), - () => - Effect.gen(function* () { - yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie) - - const exists = yield* fs.existsSafe(localPath) - const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git")) - const origin = hasGitDir - ? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) - : undefined - const originReference = - origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined - const reuse = - hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) - if (exists && !reuse) { - yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie) - } - - const currentBranch = hasGitDir ? yield* git.branch(localPath) : undefined - const status = statusForRepository({ - reuse, - refresh: params.refresh, - branchMatches: params.branch ? currentBranch === params.branch : undefined, - }) - - if (status === "cloned") { - const clone = yield* git.run( - [ - "clone", - "--depth", - "100", - ...(params.branch ? ["--branch", params.branch] : []), - "--", - remote, - localPath, - ], - { cwd: path.dirname(localPath) }, - ) - if (clone.exitCode !== 0) { - throw new Error( - clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`, - ) - } - } - - if (status === "refreshed") { - const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath }) - if (fetch.exitCode !== 0) { - throw new Error( - fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`, - ) - } - - if (params.branch) { - const checkout = yield* git.run(["checkout", "-B", params.branch, `origin/${params.branch}`], { - cwd: localPath, - }) - if (checkout.exitCode !== 0) { - throw new Error( - checkout.stderr.toString().trim() || - checkout.text().trim() || - `Failed to checkout ${params.branch}`, - ) - } - } - - const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) - const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) - const target = resetTarget({ - requestedBranch: params.branch, - remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, - branch: { code: branch.exitCode, stdout: branch.text().trim() }, - }) - - const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath }) - if (reset.exitCode !== 0) { - throw new Error( - reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`, - ) - } - } - - const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath }) - const branch = yield* git.branch(localPath) - const headText = head.exitCode === 0 ? head.text().trim() : undefined - - return { - title: repository, - metadata: { - repository, - host: reference.host, - remote, - localPath, - status, - head: headText, - branch, - }, - output: [ - `Repository ready: ${repository}`, - `Status: ${status}`, - `Local path: ${localPath}`, - ...(branch ? [`Branch: ${branch}`] : []), - ...(headText ? [`HEAD: ${headText}`] : []), - ].join("\n"), - } - }), - (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), + const result = yield* RepositoryCache.ensure( + { reference, refresh: params.refresh, branch: params.branch }, + { fs, git }, ) + return { + title: repository, + metadata: result, + output: [ + `Repository ready: ${repository}`, + `Status: ${result.status}`, + `Local path: ${localPath}`, + ...(result.branch ? [`Branch: ${result.branch}`] : []), + ...(result.head ? [`HEAD: ${result.head}`] : []), + ].join("\n"), + } }).pipe(Effect.orDie), } satisfies Tool.DefWithoutID }), diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index b6c263a4ce3d..a80d91515358 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) +const toolIdSchema = Schema.String.check(Schema.isStartsWith("tool")).pipe(Schema.brand("ToolID")) export type ToolID = typeof toolIdSchema.Type diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts index 2e78a94f416c..890fb87a324a 100644 --- a/packages/opencode/src/util/repository.ts +++ b/packages/opencode/src/util/repository.ts @@ -125,6 +125,21 @@ export function parseRepositoryReference(input: string) { } } +export function parseRemoteRepositoryReference(input: string) { + const reference = parseRepositoryReference(input) + if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") + if (reference.protocol === "file:") throw new Error("Local file repositories are not supported") + return reference +} + +export function validateRepositoryBranch(branch: string) { + if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { + throw new Error( + "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", + ) + } +} + export function parseGitHubRemote(input: string) { const cleaned = normalize(input) if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts index 83ca437efec3..14ee44dd5289 100644 --- a/packages/opencode/src/v2/event.ts +++ b/packages/opencode/src/v2/event.ts @@ -1,7 +1,6 @@ import { Identifier } from "@/id/id" import { SyncEvent } from "@/sync" import { withStatics } from "@opencode-ai/core/schema" -import { Flag } from "@opencode-ai/core/flag/flag" import * as Schema from "effect/Schema" export const ID = Schema.String.pipe( @@ -41,13 +40,4 @@ export function define( - def: Def, - data: SyncEvent.Event["data"], - options?: { publish?: boolean }, -) { - if (!Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) return - SyncEvent.run(def, data, options) -} - export * as EventV2 from "./event" diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index b3da6009f697..3b0b61dcbc61 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -12,6 +12,7 @@ import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Modelv2 } from "./model" +import { SyncEvent } from "@/sync" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", @@ -113,6 +114,7 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { + const sync = yield* SyncEvent.Service const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -269,14 +271,14 @@ export const layer = Layer.effect( shell: Effect.fn("V2Session.shell")(function* (_input) {}), skill: Effect.fn("V2Session.skill")(function* (_input) {}), switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { - EventV2.run(SessionEvent.AgentSwitched.Sync, { + yield* sync.run(SessionEvent.AgentSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), agent: input.agent, }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - EventV2.run(SessionEvent.ModelSwitched.Sync, { + yield* sync.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), model: input.model, @@ -311,6 +313,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(SyncEvent.defaultLayer)) export * as SessionV2 from "./session" diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index b7f28b85d178..bfd91687233e 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -32,7 +32,7 @@ const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1)) const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10)) const live = (client: HttpClient.HttpClient) => - Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) + Layer.fresh(Account.layer).pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) // kilocode_change const json = (req: Parameters[0], body: unknown, status = 200) => HttpClientResponse.fromWeb( diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 8be8c89ac8c8..f14db0ad0e3f 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -42,9 +42,11 @@ test("returns default native agents when no config", async () => { const names = agents.map((a) => a.name) expect(names).toContain("code") // kilocode_change expect(names).toContain("plan") - expect(names).toContain("debug") // kilocode_change - expect(names).toContain("orchestrator") // kilocode_change - expect(names).toContain("ask") // kilocode_change + // kilocode_change start + expect(names).toContain("debug") + expect(names).toContain("orchestrator") + expect(names).toContain("ask") + // kilocode_change end expect(names).toContain("general") expect(names).toContain("explore") expect(names).not.toContain("scout") @@ -67,15 +69,13 @@ test("code agent has correct default properties", async () => { expect(code?.mode).toBe("primary") expect(code?.native).toBe(true) expect(evalPerm(code, "edit")).toBe("allow") - expect(evalPerm(code, "bash")).toBe("ask") // kilocode_change - safe-bash default is ask + expect(evalPerm(code, "bash")).toBe("ask") expect(evalPerm(code, "repo_clone")).toBe("deny") expect(evalPerm(code, "repo_overview")).toBe("deny") }, }) }) -// kilocode_change end -// kilocode_change start - ask agent tests test("ask agent has correct default properties", async () => { await using tmp = await tmpdir() await WithInstance.provide({ @@ -133,9 +133,7 @@ test("ask agent denies edit/write/bash even when user config adds a specific edi }, }) }) -// kilocode_change end -// kilocode_change start test("plan agent denies edits except .kilo/plans/* and .opencode/plans/*", async () => { await using tmp = await tmpdir() await WithInstance.provide({ @@ -234,6 +232,10 @@ test("reference config creates scout-backed subagents", async () => { config: { reference: { effect: "github.com/effect/effect-smol", + effectDev: { + repository: "https://github.com/effect/effect-smol", + branch: "dev", + }, effectFull: { repository: "Effect-TS/effect", branch: "main", @@ -249,6 +251,7 @@ test("reference config creates scout-backed subagents", async () => { directory: tmp.path, fn: async () => { const effect = await load(tmp.path, (svc) => svc.get("effect")) + const effectDev = await load(tmp.path, (svc) => svc.get("effectDev")) const effectFull = await load(tmp.path, (svc) => svc.get("effectFull")) const local = await load(tmp.path, (svc) => svc.get("localdocs")) const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull")) @@ -256,13 +259,21 @@ test("reference config creates scout-backed subagents", async () => { expect(effect).toBeDefined() expect(effect?.mode).toBe("subagent") expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol") - expect(evalPerm(effect, "repo_clone")).toBe("allow") + expect(effect?.prompt).toContain( + `Cached directory: ${path.join(Global.Path.repos, "github.com", "effect", "effect-smol")}`, + ) + expect(effect?.prompt).toContain("Do not call repo_clone") + expect(evalPerm(effect, "repo_clone")).toBe("deny") + + expect(effectDev).toBeDefined() + expect(effectDev?.prompt).toContain("Problem: Reference conflicts with @effect") + expect(effectDev?.prompt).not.toContain("Cached directory:") expect(effectFull).toBeDefined() expect(effectFull?.mode).toBe("subagent") expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect") expect(effectFull?.prompt).toContain("Branch/ref: main") - expect(evalPerm(effectFull, "repo_clone")).toBe("allow") + expect(evalPerm(effectFull, "repo_clone")).toBe("deny") expect(local).toBeDefined() expect(local?.mode).toBe("subagent") @@ -526,6 +537,7 @@ test("unknown agent properties are placed into options", async () => { config: { agent: { code: { + // kilocode_change random_property: "hello", another_random: 123, }, @@ -945,7 +957,6 @@ test("defaultAgent returns plan when code is disabled and default_agent not set" directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) - // kilocode_change - code is disabled, so it should return plan (next primary agent) expect(agent).toBe("plan") }, }) @@ -968,7 +979,6 @@ test("defaultAgent throws when all primary agents are disabled", async () => { await WithInstance.provide({ directory: tmp.path, fn: async () => { - // kilocode_change - all primary agents are disabled await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow("no primary visible agent found") }, }) diff --git a/packages/opencode/test/cli/cmd/tui/app-exit.test.ts b/packages/opencode/test/cli/cmd/tui/app-exit.test.ts new file mode 100644 index 000000000000..d1adf4fb8ebb --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/app-exit.test.ts @@ -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 exit when the command matcher is disabled", () => { + const bindings = TuiConfig.resolve({}).keybinds.gather("app_exit", ["app.exit"]) + + expect(bindings.length).toBeGreaterThan(0) + 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) + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts index 34a16aedd6fa..a7b1643357c9 100644 --- a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts +++ b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts @@ -3,36 +3,27 @@ import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/promp describe("computePromptTraits", () => { test("normal mode without autocomplete only captures tab", () => { - const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: false }) + const traits = computePromptTraits({ mode: "normal", autocompleteVisible: false }) expect(traits.capture).toEqual(["tab"]) - expect(traits.suspend).toBe(false) + expect(traits.suspend).toBeUndefined() expect(traits.status).toBeUndefined() }) test("normal mode with autocomplete captures navigation keys", () => { - const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: true }) + const traits = computePromptTraits({ mode: "normal", autocompleteVisible: true }) expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"]) - expect(traits.suspend).toBe(false) + expect(traits.suspend).toBeUndefined() expect(traits.status).toBeUndefined() }) - test("shell mode does not suspend the textarea", () => { - // Suspending the textarea would gate every keybinding action - // (backspace, delete-word-backward, arrow movement, etc.) — see - // @opentui/core 0.2.x TextareaRenderable.handleKeyPress. Shell mode is - // an active editing mode, so suspend must stay off. - const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) - expect(traits.suspend).toBe(false) + test("shell mode does not write the keymap-owned suspend trait", () => { + const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false }) + expect(traits.suspend).toBeUndefined() }) test("shell mode disables capture and labels the prompt", () => { - const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) + const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false }) expect(traits.capture).toBeUndefined() expect(traits.status).toBe("SHELL") }) - - test("disabled suspends regardless of mode", () => { - expect(computePromptTraits({ mode: "normal", disabled: true, autocompleteVisible: false }).suspend).toBe(true) - expect(computePromptTraits({ mode: "shell", disabled: true, autocompleteVisible: false }).suspend).toBe(true) - }) }) diff --git a/packages/opencode/test/cli/github-action.test.ts b/packages/opencode/test/cli/github-action.test.ts index 279ed27d0829..263f3a45f318 100644 --- a/packages/opencode/test/cli/github-action.test.ts +++ b/packages/opencode/test/cli/github-action.test.ts @@ -7,8 +7,8 @@ import { SessionID, MessageID, PartID } from "../../src/session/schema" function createTextPart(text: string): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "text" as const, text, } @@ -17,8 +17,8 @@ function createTextPart(text: string): MessageV2.Part { function createReasoningPart(text: string): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "reasoning" as const, text, time: { start: 0 }, @@ -29,8 +29,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn if (status === "completed") { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "tool" as const, callID: "c1", tool, @@ -46,8 +46,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn } return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "tool" as const, callID: "c1", tool, @@ -62,8 +62,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn function createStepStartPart(): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "step-start" as const, } } @@ -71,8 +71,8 @@ function createStepStartPart(): MessageV2.Part { function createStepFinishPart(): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "step-finish" as const, reason: "done", cost: 0, diff --git a/packages/opencode/test/image/image.test.ts b/packages/opencode/test/image/image.test.ts new file mode 100644 index 000000000000..bbcca75777cc --- /dev/null +++ b/packages/opencode/test/image/image.test.ts @@ -0,0 +1,122 @@ +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" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(Image.layer.pipe(Layer.provide(TestConfig.layer())))) +const tiny = testEffect( + Layer.mergeAll( + Image.layer.pipe( + Layer.provide( + TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) }), + ), + ), + ), +) + +function part(mime: string, data: string) { + return { + id: PartID.ascending(), + messageID: MessageID.ascending(), + sessionID: SessionID.make("ses_test"), + type: "file" as const, + mime, + url: `data:${mime};base64,${data}`, + } +} + +describe("Image", () => { + it.effect("normalizes generated png and jpeg attachments", () => + Effect.gen(function* () { + const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) + const source = new photon.PhotonImage( + new Uint8Array(Array.from({ length: 64 * 64 * 4 }, (_, index) => (index % 4 === 3 ? 255 : index % 251))), + 64, + 64, + ) + const image = yield* Image.Service + const results = yield* Effect.all([ + image.normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))), + image.normalize(part("image/jpeg", Buffer.from(source.get_bytes_jpeg(90)).toString("base64"))), + ]) + + source.free() + expect(results.map((result) => result.url.startsWith(`data:${result.mime};base64,`))).toEqual([true, true]) + expect(results.every((result) => result.mime === "image/png" || result.mime === "image/jpeg")).toBe(true) + }), + ) + + it.effect("accepts webp attachments that are already within limits", () => + Effect.gen(function* () { + const image = yield* Image.Service + const input = part("image/webp", "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA") + + expect(yield* image.normalize(input)).toEqual(input) + }), + ) + + // 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")) + const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 4 }, () => 255)), 1, 1) + const image = yield* Image.Service + const exit = yield* image + .normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))) + .pipe(Effect.exit) + + source.free() + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Image.SizeError) + if (error instanceof Image.SizeError) { + expect(error.width).toBe(1) + expect(error.height).toBe(1) + expect(error.max).toBe(1) + } + } + }), + ) +}) diff --git a/packages/opencode/test/kilocode/bash-permission-metadata.test.ts b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts index df1be7a1b799..c5190d0ef795 100644 --- a/packages/opencode/test/kilocode/bash-permission-metadata.test.ts +++ b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts @@ -29,7 +29,7 @@ Shell.acceptable.reset() const baseCtx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "code", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts b/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts index 35b25c1dd042..36df07576c4d 100644 --- a/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts +++ b/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts @@ -5,7 +5,9 @@ import * as Stream from "effect/Stream" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { Image } from "../../src/image/image" import { KiloCompactionPayloadRecovery } from "../../src/kilocode/session/compaction-payload-recovery" +import { KiloSessionCompaction } from "../../src/kilocode/session/compaction" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { WithInstance } from "../../src/project/with-instance" @@ -19,6 +21,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionCompaction } from "../../src/session/compaction" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" +import { SyncEvent } from "../../src/sync" import { ProviderTest } from "../fake/provider" import { tmpdir } from "../fixture/fixture" @@ -174,7 +177,11 @@ function reply( function runtime(layer: Layer.Layer, config = Config.defaultLayer) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) + const processor = SessionProcessorModule.SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + ) const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 100_000, output: 32_000 } }) return ManagedRuntime.make( Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( @@ -188,6 +195,7 @@ function runtime(layer: Layer.Layer, config = Config.defaultLayer) Layer.provide(status), Layer.provide(bus), Layer.provide(config), + Layer.provide(SyncEvent.defaultLayer), ), ) } @@ -355,12 +363,18 @@ describe("KiloCompactionPayloadRecovery", () => { time: { start: Date.now(), end: Date.now() }, }, }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + await Effect.runPromise( + KiloSessionCompaction.create({ + session: { + updateMessage: (msg) => Effect.promise(() => svc.updateMessage(msg)), + updatePart: (part) => Effect.promise(() => svc.updatePart(part)), + }, + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }), + ) const rt = runtime(stub.layer, Config.defaultLayer) try { diff --git a/packages/opencode/test/kilocode/edit-permission-filediff.test.ts b/packages/opencode/test/kilocode/edit-permission-filediff.test.ts index cf9420bf90f8..598365f03dae 100644 --- a/packages/opencode/test/kilocode/edit-permission-filediff.test.ts +++ b/packages/opencode/test/kilocode/edit-permission-filediff.test.ts @@ -1,4 +1,3 @@ -// kilocode_change - new file // // Ensure the edit tool always includes `filediff` in its // permission-ask metadata. Without `filediff`, the VS Code extension's @@ -49,7 +48,7 @@ function capture() { const requests: Array<{ permission: string; metadata: Record }> = [] const ctx = { sessionID: SessionID.make("ses_test-edit-filediff"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test-edit-filediff"), callID: "", agent: "code", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/kilocode/external-directory-boundary.test.ts b/packages/opencode/test/kilocode/external-directory-boundary.test.ts index 1fcab67a8e46..e594e5c3f73b 100644 --- a/packages/opencode/test/kilocode/external-directory-boundary.test.ts +++ b/packages/opencode/test/kilocode/external-directory-boundary.test.ts @@ -14,7 +14,7 @@ import { tmpdir } from "../fixture/fixture" const base: Omit = { sessionID: SessionID.make("ses_test-boundary-session"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test-boundary-session"), callID: "", agent: "code", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/kilocode/permission/env-read.test.ts b/packages/opencode/test/kilocode/permission/env-read.test.ts index 6eb67e77bca7..54ec1111ad12 100644 --- a/packages/opencode/test/kilocode/permission/env-read.test.ts +++ b/packages/opencode/test/kilocode/permission/env-read.test.ts @@ -144,7 +144,7 @@ describe("env read permissions", () => { }).pipe(Effect.forkScoped) yield* waitForPending(1) - yield* allow({ enable: true, requestID: "per_env_everything" }) + yield* allow({ enable: true, requestID: PermissionID.make("per_env_everything") }) const items = yield* waitForPending(1) expect(items[0].id).toBe(PermissionID.make("per_env_everything")) diff --git a/packages/opencode/test/kilocode/question-dismiss-all.test.ts b/packages/opencode/test/kilocode/question-dismiss-all.test.ts index 8b41c6bb2fb0..da978a86839d 100644 --- a/packages/opencode/test/kilocode/question-dismiss-all.test.ts +++ b/packages/opencode/test/kilocode/question-dismiss-all.test.ts @@ -86,7 +86,7 @@ describe("Question.dismissAll", () => { const first = yield* KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_ask_1"), + MessageID.make("msg_ask_1"), Effect.gen(function* () { started.resolve() yield* Effect.promise(() => release.promise) @@ -98,7 +98,7 @@ describe("Question.dismissAll", () => { const second = yield* KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_ask_2"), + MessageID.make("msg_ask_2"), Effect.succeed("second" as const), Effect.succeed("second-cancelled" as const), ).pipe(Effect.forkScoped) diff --git a/packages/opencode/test/kilocode/read-directory.test.ts b/packages/opencode/test/kilocode/read-directory.test.ts index bcb639183c2b..120f320cba92 100644 --- a/packages/opencode/test/kilocode/read-directory.test.ts +++ b/packages/opencode/test/kilocode/read-directory.test.ts @@ -16,7 +16,7 @@ import { testEffect } from "../lib/effect" const baseCtx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "code", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/kilocode/read-docx.test.ts b/packages/opencode/test/kilocode/read-docx.test.ts index 346727a19770..a3ec83675017 100644 --- a/packages/opencode/test/kilocode/read-docx.test.ts +++ b/packages/opencode/test/kilocode/read-docx.test.ts @@ -16,7 +16,7 @@ import { testEffect } from "../lib/effect" const ctx: Tool.Context = { sessionID: SessionID.make("ses_test-docx"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test-docx"), callID: "", agent: "code", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/kilocode/read-notebook.test.ts b/packages/opencode/test/kilocode/read-notebook.test.ts index e4a6fa2da1e5..603b28cc542e 100644 --- a/packages/opencode/test/kilocode/read-notebook.test.ts +++ b/packages/opencode/test/kilocode/read-notebook.test.ts @@ -15,7 +15,7 @@ import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test-notebook"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test-notebook"), callID: "", agent: "code", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/kilocode/read-xlsx.test.ts b/packages/opencode/test/kilocode/read-xlsx.test.ts index 7862ade115ab..4180d18f9a14 100644 --- a/packages/opencode/test/kilocode/read-xlsx.test.ts +++ b/packages/opencode/test/kilocode/read-xlsx.test.ts @@ -18,7 +18,7 @@ import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "code", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/kilocode/semantic-search.test.ts b/packages/opencode/test/kilocode/semantic-search.test.ts index b4bd89b01737..2e69a77efae1 100644 --- a/packages/opencode/test/kilocode/semantic-search.test.ts +++ b/packages/opencode/test/kilocode/semantic-search.test.ts @@ -24,7 +24,7 @@ async function initTool() { const baseCtx = { sessionID: SessionID.make("ses_test-semantic-search"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test-semantic-search"), callID: "", agent: "code", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts b/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts index 27b16cef0b21..d3be9a259166 100644 --- a/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts +++ b/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts @@ -11,6 +11,7 @@ import { ModelCache } from "../../../src/provider/model-cache" import { Session } from "../../../src/session/session" import { Authorization } from "../../../src/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "../../../src/server/routes/instance/httpapi/middleware/instance-context" +import { schemaErrorLayer } from "../../../src/server/routes/instance/httpapi/middleware/schema-error" import { WorkspaceRouteContext, WorkspaceRoutingMiddleware, @@ -41,6 +42,7 @@ const testWorkspaceRouting = Layer.succeed( const layer = HttpRouter.serve( HttpApiBuilder.layer(TestHttpApi).pipe( Layer.provide(kiloGatewayHandlers), + Layer.provide(schemaErrorLayer), Layer.provide([ passthroughAuthorization, passthroughInstanceContext, diff --git a/packages/opencode/test/kilocode/session-compaction-cap.test.ts b/packages/opencode/test/kilocode/session-compaction-cap.test.ts index 239e63029a25..abe58dbcef4d 100644 --- a/packages/opencode/test/kilocode/session-compaction-cap.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-cap.test.ts @@ -17,6 +17,7 @@ import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" import { Git } from "../../src/git" +import { Image } from "../../src/image/image" import { KiloSession } from "../../src/kilocode/session" import { KiloSessionPrompt } from "../../src/kilocode/session/prompt" import { LSP } from "../../src/lsp/lsp" @@ -26,6 +27,7 @@ import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "../../src/provider/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" +import { Reference } from "../../src/reference/reference" import { Session } from "../../src/session/session" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" @@ -42,6 +44,7 @@ import { SessionSummary } from "../../src/session/summary" import { Todo } from "../../src/session/todo" import { Skill } from "../../src/skill" import { Snapshot } from "../../src/snapshot" +import { SyncEvent } from "../../src/sync" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import * as Log from "@opencode-ai/core/util/log" @@ -137,6 +140,8 @@ function makeHttp() { lsp, mcp, AppFileSystem.defaultLayer, + SyncEvent.defaultLayer, + Reference.defaultLayer, status, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) @@ -153,19 +158,24 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provideMerge(deps), + ) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(summary), Layer.provideMerge(runState), Layer.provideMerge(compact), Layer.provideMerge(proc), Layer.provideMerge(registry), Layer.provideMerge(trunc), - Layer.provideMerge(question), // kilocode_change - SessionPrompt now dismisses questions via its service dependency + Layer.provideMerge(question), Layer.provide(Instruction.defaultLayer), Layer.provide(SystemPrompt.defaultLayer), Layer.provideMerge(deps), diff --git a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts index ecaf77edf5c3..3abe10d4b5ab 100644 --- a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts @@ -4,12 +4,14 @@ import * as Stream from "effect/Stream" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { Image } from "../../src/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { WithInstance } from "../../src/project/with-instance" import { ModelID, ProviderID } from "../../src/provider/schema" import { Snapshot } from "../../src/snapshot" import { KiloCompactionChunks } from "../../src/kilocode/session/compaction-chunks" +import { KiloSessionCompaction } from "../../src/kilocode/session/compaction" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { SessionCompaction } from "../../src/session/compaction" @@ -19,6 +21,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Session as SessionNs } from "../../src/session/session" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" +import { SyncEvent } from "../../src/sync" import { ProviderTest } from "../fake/provider" import { tmpdir } from "../fixture/fixture" @@ -30,6 +33,11 @@ function run(fx: Effect.Effect) { return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) } +const store = { + updateMessage: (msg: T) => Effect.promise(() => svc.updateMessage(msg)), + updatePart: (part: T) => Effect.promise(() => svc.updatePart(part)), +} + const svc = { create(input?: SessionNs.CreateInput) { return run(SessionNs.Service.use((svc) => svc.create(input))) @@ -195,7 +203,11 @@ function overflow() { function runtime(layer: Layer.Layer, context = 7_000) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) + const processor = SessionProcessorModule.SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + ) const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } }) return ManagedRuntime.make( Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( @@ -206,6 +218,7 @@ function runtime(layer: Layer.Layer, context = 7_000) { Layer.provide(Permission.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Plugin.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), Layer.provide(status), Layer.provide(bus), Layer.provide( @@ -270,6 +283,7 @@ function fakeRuntime() { Layer.provide(SessionNs.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Plugin.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), Layer.provide(bus), Layer.provide( Layer.mock(Config.Service)({ @@ -284,7 +298,11 @@ function fakeRuntime() { function liveRuntime(layer: Layer.Layer, context = 10_000) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) + const processor = SessionProcessorModule.SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + ) const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } }) return ManagedRuntime.make( Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( @@ -295,6 +313,7 @@ function liveRuntime(layer: Layer.Layer, context = 10_000) { Layer.provide(Permission.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Plugin.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), Layer.provide(status), Layer.provide(bus), Layer.provide( @@ -352,7 +371,9 @@ describe("KiloCompactionChunks", () => { await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(10_000)) const second = await user(session.id, "second " + "c".repeat(10_000)) await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(10_000)) - await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + await Effect.runPromise( + KiloSessionCompaction.create({ session: store, sessionID: session.id, agent: "build", model: ref, auto: false }), + ) const { rt, calls } = fakeRuntime() try { @@ -398,7 +419,9 @@ describe("KiloCompactionChunks", () => { await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(10_000)) const second = await user(session.id, "second " + "c".repeat(10_000)) await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(10_000)) - await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + await Effect.runPromise( + KiloSessionCompaction.create({ session: store, sessionID: session.id, agent: "build", model: ref, auto: false }), + ) const { rt, calls } = fakeRuntime() try { @@ -434,7 +457,9 @@ describe("KiloCompactionChunks", () => { const session = await svc.create({}) const first = await user(session.id, "first " + "a".repeat(20_000)) await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(20_000)) - await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + await Effect.runPromise( + KiloSessionCompaction.create({ session: store, sessionID: session.id, agent: "build", model: ref, auto: false }), + ) const { rt, calls } = fakeRuntime() try { @@ -477,7 +502,9 @@ describe("KiloCompactionChunks", () => { fn: async () => { const session = await svc.create({}) const first = await user(session.id, "single huge request " + "a".repeat(80_000)) - await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + await Effect.runPromise( + KiloSessionCompaction.create({ session: store, sessionID: session.id, agent: "build", model: ref, auto: false }), + ) const { rt, calls } = fakeRuntime() try { @@ -515,7 +542,9 @@ describe("KiloCompactionChunks", () => { const session = await svc.create({}) const first = await user(session.id, "first " + "a".repeat(1_000)) await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(1_000)) - await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + await Effect.runPromise( + KiloSessionCompaction.create({ session: store, sessionID: session.id, agent: "build", model: ref, auto: false }), + ) try { const msgs = await svc.messages({ sessionID: session.id }) @@ -556,13 +585,16 @@ describe("KiloCompactionChunks", () => { const old = await user(session.id, "old context") await assistant(session.id, old.id, tmp.path, "old reply") const large = await user(session.id, "large replay " + "x".repeat(40_000)) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: true, - overflow: true, - }) + await Effect.runPromise( + KiloSessionCompaction.create({ + session: store, + sessionID: session.id, + agent: "build", + model: ref, + auto: true, + overflow: true, + }), + ) const rt = liveRuntime(stub.layer) try { diff --git a/packages/opencode/test/kilocode/session-compaction-safety.test.ts b/packages/opencode/test/kilocode/session-compaction-safety.test.ts index 9ba3694cbb87..b5d7dcf32509 100644 --- a/packages/opencode/test/kilocode/session-compaction-safety.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-safety.test.ts @@ -48,7 +48,7 @@ function assistantInfo( } as unknown as MessageV2.Assistant } -function textPart(messageID: string, text: string, partID = "p_" + messageID): MessageV2.TextPart { +function textPart(messageID: string, text: string, partID = "prt_" + messageID): MessageV2.TextPart { return { id: PartID.make(partID), sessionID, @@ -58,7 +58,7 @@ function textPart(messageID: string, text: string, partID = "p_" + messageID): M } } -function syntheticTextPart(messageID: string, text: string, partID = "p_syn_" + messageID): MessageV2.TextPart { +function syntheticTextPart(messageID: string, text: string, partID = "prt_syn_" + messageID): MessageV2.TextPart { return { id: PartID.make(partID), sessionID, @@ -71,7 +71,7 @@ function syntheticTextPart(messageID: string, text: string, partID = "p_syn_" + function compactionPart(messageID: string, tailStartID: string): MessageV2.CompactionPart { return { - id: PartID.make("p_compact_" + messageID), + id: PartID.make("prt_compact_" + messageID), sessionID, messageID: MessageID.make(messageID), type: "compaction", @@ -82,7 +82,7 @@ function compactionPart(messageID: string, tailStartID: string): MessageV2.Compa function subtaskPart(messageID: string): MessageV2.SubtaskPart { return { - id: PartID.make("p_subtask_" + messageID), + id: PartID.make("prt_subtask_" + messageID), sessionID, messageID: MessageID.make(messageID), type: "subtask", @@ -96,7 +96,7 @@ function filePart( messageID: string, mime: string, filename: string | undefined, - partID = "p_file_" + messageID, + partID = "prt_file_" + messageID, ): MessageV2.FilePart { return { id: PartID.make(partID), @@ -113,7 +113,7 @@ function toolPart( messageID: string, status: "completed" | "error" | "pending" | "running", attachments?: MessageV2.FilePart[], - partID = "p_tool_" + messageID, + partID = "prt_tool_" + messageID, ): MessageV2.ToolPart { const state = (() => { if (status === "completed") { @@ -411,8 +411,8 @@ describe("KiloSessionPrompt.stripHistoricalMedia", () => { }) test("does NOT touch text/plain or directory file parts", () => { - const textFile = filePart("msg_hist", "text/plain", "notes.txt", "p_txt") - const dirFile = filePart("msg_hist", "application/x-directory", "src/", "p_dir") + const textFile = filePart("msg_hist", "text/plain", "notes.txt", "prt_txt") + const dirFile = filePart("msg_hist", "application/x-directory", "src/", "prt_dir") const msgs = [user("msg_hist", [textFile, dirFile]), user("msg_last", [textPart("msg_last", "follow-up")])] const result = KiloSessionPrompt.stripHistoricalMedia(msgs) expect(result[0].parts[0]).toBe(textFile) @@ -420,9 +420,9 @@ describe("KiloSessionPrompt.stripHistoricalMedia", () => { }) test("filters media attachments out of completed tool parts, keeps non-media", () => { - const imageAtt = filePart("msg_tool", "image/png", "shot.png", "p_att_img") - const textAtt = filePart("msg_tool", "text/plain", "data.txt", "p_att_txt") - const pdfAtt = filePart("msg_tool", "application/pdf", "doc.pdf", "p_att_pdf") + const imageAtt = filePart("msg_tool", "image/png", "shot.png", "prt_att_img") + const textAtt = filePart("msg_tool", "text/plain", "data.txt", "prt_att_txt") + const pdfAtt = filePart("msg_tool", "application/pdf", "doc.pdf", "prt_att_pdf") const tool = toolPart("msg_tool", "completed", [imageAtt, textAtt, pdfAtt]) const msgs = [ user("msg_u1", [textPart("msg_u1", "question")]), @@ -526,7 +526,7 @@ describe("KiloSessionPrompt.stripHistoricalMedia", () => { user("msg_current", [textPart("msg_current", "check this"), currentImage]), user("msg_syn", [ syntheticTextPart("msg_syn", "Summarize the task tool output above and continue with your task."), - syntheticTextPart("msg_syn", "\nCurrent time: now\n", "p_env"), + syntheticTextPart("msg_syn", "\nCurrent time: now\n", "prt_env"), ]), ] const result = KiloSessionPrompt.stripHistoricalMedia(msgs) diff --git a/packages/opencode/test/kilocode/session-export/capture.test.ts b/packages/opencode/test/kilocode/session-export/capture.test.ts index d7a0a0f7e20a..f3f6fb894bdb 100644 --- a/packages/opencode/test/kilocode/session-export/capture.test.ts +++ b/packages/opencode/test/kilocode/session-export/capture.test.ts @@ -437,7 +437,7 @@ describe("Capture", () => { requestId: "rA", input: { inputMessagesSnapshot: [{ role: "user", content: "..." }], - selectedContext: context("s1"), + selectedContext: context("ses_1"), prompt: "Summarize the conversation so far.", }, output: { summary: "Discussed X.", assistantMessageId: "aA" }, diff --git a/packages/opencode/test/kilocode/session-message-metadata.test.ts b/packages/opencode/test/kilocode/session-message-metadata.test.ts index 5fa865fb0c01..785ae99dc6f8 100644 --- a/packages/opencode/test/kilocode/session-message-metadata.test.ts +++ b/packages/opencode/test/kilocode/session-message-metadata.test.ts @@ -1,4 +1,3 @@ -// kilocode_change - new file import { describe, expect, test } from "bun:test" import { MessageV2 } from "../../src/session/message-v2" @@ -15,9 +14,9 @@ function blob(size: number) { function part(tool: string, metadata: Record): MessageV2.Part { return { - id: PartID.make(`p-${tool}`), + id: PartID.make(`prt-${tool}`), sessionID, - messageID: MessageID.make("m-assistant"), + messageID: MessageID.make("msg-assistant"), type: "tool", callID: `call-${tool}`, tool, diff --git a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts index 9f8ec2f660f0..75949c0c3833 100644 --- a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts +++ b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts @@ -6,6 +6,7 @@ import path from "path" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { Image } from "../../src/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import type { Provider } from "../../src/provider/provider" @@ -18,6 +19,7 @@ import { MessageID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { Snapshot } from "../../src/snapshot" +import { SyncEvent } from "../../src/sync" import { KiloSessionProcessor } from "../../src/kilocode/session/processor" import * as Log from "@opencode-ai/core/util/log" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" @@ -101,6 +103,8 @@ const deps = Layer.mergeAll( Plugin.defaultLayer, Config.defaultLayer, SessionSummary.defaultLayer, + Image.defaultLayer, + SyncEvent.defaultLayer, status, llm, ).pipe(Layer.provideMerge(infra)) diff --git a/packages/opencode/test/kilocode/session-processor-network-offline.test.ts b/packages/opencode/test/kilocode/session-processor-network-offline.test.ts index e9deb7dfb0e3..2dd18ff5dca6 100644 --- a/packages/opencode/test/kilocode/session-processor-network-offline.test.ts +++ b/packages/opencode/test/kilocode/session-processor-network-offline.test.ts @@ -6,6 +6,7 @@ import path from "path" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { Image } from "../../src/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import type { Provider } from "../../src/provider/provider" @@ -19,6 +20,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { Snapshot } from "../../src/snapshot" +import { SyncEvent } from "../../src/sync" import * as Log from "@opencode-ai/core/util/log" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" @@ -100,6 +102,8 @@ const deps = Layer.mergeAll( Plugin.defaultLayer, Config.defaultLayer, SessionSummary.defaultLayer, + Image.defaultLayer, + SyncEvent.defaultLayer, status, llm, ).pipe(Layer.provideMerge(infra)) diff --git a/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts b/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts index 45866a37d2c5..91944a86bc76 100644 --- a/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts +++ b/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts @@ -13,6 +13,7 @@ import path from "path" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { Image } from "../../src/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import type { Provider } from "../../src/provider/provider" @@ -26,6 +27,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { Snapshot } from "../../src/snapshot" +import { SyncEvent } from "../../src/sync" import * as Log from "@opencode-ai/core/util/log" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" @@ -113,6 +115,8 @@ const deps = Layer.mergeAll( Plugin.defaultLayer, Config.defaultLayer, SessionSummary.defaultLayer, + Image.defaultLayer, + SyncEvent.defaultLayer, status, llm, ).pipe(Layer.provideMerge(infra)) diff --git a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts index 90ff2eace58a..ffb4e394fa33 100644 --- a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts @@ -16,6 +16,7 @@ import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" import { Git } from "../../src/git" +import { Image } from "../../src/image/image" import { LSP } from "../../src/lsp/lsp" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" @@ -23,6 +24,7 @@ import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "../../src/provider/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" +import { Reference } from "../../src/reference/reference" import { Session } from "../../src/session/session" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" @@ -39,6 +41,7 @@ import { SessionSummary } from "../../src/session/summary" import { Todo } from "../../src/session/todo" import { Skill } from "../../src/skill" import { Snapshot } from "../../src/snapshot" +import { SyncEvent } from "../../src/sync" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import * as Log from "@opencode-ai/core/util/log" @@ -130,6 +133,7 @@ function makeHttp() { lsp, mcp, AppFileSystem.defaultLayer, + SyncEvent.defaultLayer, status, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) @@ -141,24 +145,30 @@ function makeHttp() { Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provideMerge(deps), + ) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(summary), Layer.provideMerge(run), Layer.provideMerge(compact), Layer.provideMerge(proc), Layer.provideMerge(registry), Layer.provideMerge(trunc), - Layer.provideMerge(question), // kilocode_change - SessionPrompt now dismisses questions via its service dependency + Layer.provideMerge(question), Layer.provide(Instruction.defaultLayer), Layer.provide(SystemPrompt.defaultLayer), Layer.provideMerge(deps), diff --git a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts index 0140de9cc348..cac14071d4ce 100644 --- a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts @@ -13,12 +13,14 @@ import { Config } from "../../src/config/config" import { Env } from "../../src/env" import { Format } from "../../src/format" import { Git } from "../../src/git" +import { Image } from "../../src/image/image" import { LSP } from "../../src/lsp/lsp" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "../../src/provider/provider" import { Question } from "../../src/question" +import { Reference } from "../../src/reference/reference" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { LLM } from "../../src/session/llm" @@ -33,6 +35,7 @@ import { SessionSummary } from "../../src/session/summary" import { Todo } from "../../src/session/todo" import { Skill } from "../../src/skill" import { Snapshot } from "../../src/snapshot" +import { SyncEvent } from "../../src/sync" import { Ripgrep } from "../../src/file/ripgrep" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" @@ -124,6 +127,7 @@ function makeHttp() { lsp, mcp, AppFileSystem.defaultLayer, + SyncEvent.defaultLayer, status, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) @@ -135,17 +139,23 @@ function makeHttp() { Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provideMerge(deps), + ) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(summary), Layer.provideMerge(run), Layer.provideMerge(compact), diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index fcaffed00f08..6229f091421d 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -4,6 +4,7 @@ import { Effect } from "effect" import { Bus } from "../../src/bus" import { AppRuntime } from "../../src/effect/app-runtime" import { InstanceRef } from "../../src/effect/instance-ref" +import { KiloSessionCompaction } from "@/kilocode/session/compaction" import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" import { Suggestion } from "../../src/kilocode/suggestion" import { ModelID, ProviderID } from "../../src/provider/schema" @@ -19,6 +20,11 @@ import { provideInstance, tmpdir } from "../fixture/fixture" Log.init({ print: false }) +const store = { + updateMessage: (msg: T) => Effect.promise(() => sessions.updateMessage(msg)), + updatePart: (part: T) => Effect.promise(() => sessions.updatePart(part)), +} + const sessions = { create: (input?: Parameters[0]) => Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))), @@ -26,6 +32,8 @@ const sessions = { Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))), updateMessage: (msg: T) => Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))), + updatePart: (part: T) => + Effect.runPromise(Session.Service.use((svc) => svc.updatePart(part)).pipe(Effect.provide(Session.defaultLayer))), } function line(input: unknown) { @@ -138,10 +146,10 @@ function assistant(sessionID: SessionID, id: MessageID, parentID: MessageID): Me describe("session prompt queue", () => { test("scopes queued turns without moving prior assistant history", async () => { const sessionID = SessionID.make("session_scope") - const one = MessageID.make("message_01") - const ans = MessageID.make("message_02") - const two = MessageID.make("message_03") - const three = MessageID.make("message_04") + const one = MessageID.make("msg_01") + const ans = MessageID.make("msg_02") + const two = MessageID.make("msg_03") + const three = MessageID.make("msg_04") const messages = [ user(sessionID, one), assistant(sessionID, ans, one), @@ -168,13 +176,13 @@ describe("session prompt queue", () => { // in the middle of the prior turn's messages, ending the next model request // with an assistant message and tripping Anthropic's prefill rejection. const sessionID = SessionID.make("session_queue_mid_turn") - const m1 = MessageID.make("message_10") - const a1 = MessageID.make("message_20") - const m2 = MessageID.make("message_30") - const a2step1 = MessageID.make("message_40") - const m3 = MessageID.make("message_50") // queued mid-turn - const a2step2 = MessageID.make("message_60") - const a2final = MessageID.make("message_70") + const m1 = MessageID.make("msg_10") + const a1 = MessageID.make("msg_20") + const m2 = MessageID.make("msg_30") + const a2step1 = MessageID.make("msg_40") + const m3 = MessageID.make("msg_50") // queued mid-turn + const a2step2 = MessageID.make("msg_60") + const a2final = MessageID.make("msg_70") const messages = [ user(sessionID, m1), assistant(sessionID, a1, m1), @@ -203,11 +211,11 @@ describe("session prompt queue", () => { // subsequent scope() calls should keep the target user together with its // own turn's assistants (not interleaved with a prior turn's tail). const sessionID = SessionID.make("session_queue_step_two") - const m1 = MessageID.make("message_01a") - const a1 = MessageID.make("message_02a") - const m2 = MessageID.make("message_03a") // queued mid-turn - const a1tail = MessageID.make("message_04a") - const a2step1 = MessageID.make("message_05a") + const m1 = MessageID.make("msg_01a") + const a1 = MessageID.make("msg_02a") + const m2 = MessageID.make("msg_03a") // queued mid-turn + const a1tail = MessageID.make("msg_04a") + const a2step1 = MessageID.make("msg_05a") const messages = [ user(sessionID, m1), assistant(sessionID, a1, m1), @@ -233,10 +241,10 @@ describe("session prompt queue", () => { // which unhid any user prompts queued between the base and the injected // follow-up. Exempt the follow-up without reopening the boundary. const sessionID = SessionID.make("session_retarget_hide") - const base = MessageID.make("message_b1") - const ans = MessageID.make("message_b2") - const queued = MessageID.make("message_b3") // queued while base was running - const injected = MessageID.make("message_b4") // injected follow-up + const base = MessageID.make("msg_b1") + const ans = MessageID.make("msg_b2") + const queued = MessageID.make("msg_b3") // queued while base was running + const injected = MessageID.make("msg_b4") // injected follow-up const messages = [ user(sessionID, base), assistant(sessionID, ans, base), @@ -284,13 +292,16 @@ describe("session prompt queue", () => { session.id, queued, Effect.promise(async () => { - await SessionCompaction.create({ - sessionID: session.id, - agent: "code", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("model") }, - auto: true, - overflow: true, - }) + await Effect.runPromise( + KiloSessionCompaction.create({ + session: store, + sessionID: session.id, + agent: "code", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("model") }, + auto: true, + overflow: true, + }), + ) const messages = await sessions.messages({ sessionID: session.id }) const compact = messages.find((msg) => msg.parts.some((part) => part.type === "compaction"))?.info.id return { compact, ids: KiloSessionPromptQueue.scope(session.id, messages).map((item) => item.info.id) } @@ -317,7 +328,7 @@ describe("session prompt queue", () => { const first = Effect.runPromise( KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_followup_1"), + MessageID.make("msg_followup_1"), Effect.gen(function* () { observed.push({ where: "first:start", value: KiloSessionPromptQueue.hasFollowup(sessionID) }) firstStarted.resolve() @@ -336,7 +347,7 @@ describe("session prompt queue", () => { const second = Effect.runPromise( KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_followup_2"), + MessageID.make("msg_followup_2"), Effect.gen(function* () { observed.push({ where: "second:start", value: KiloSessionPromptQueue.hasFollowup(sessionID) }) secondStarted.resolve() @@ -355,7 +366,7 @@ describe("session prompt queue", () => { const third = Effect.runPromise( KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_followup_3"), + MessageID.make("msg_followup_3"), Effect.sync(() => { observed.push({ where: "third:start", value: KiloSessionPromptQueue.hasFollowup(sessionID) }) return "third" @@ -673,7 +684,7 @@ describe("session prompt queue", () => { const ids = await Effect.runPromise( KiloSessionPromptQueue.enqueue( session.id, - MessageID.make("message_probe"), + MessageID.make("msg_probe"), Effect.succeed(KiloSessionPromptQueue.scope(session.id, []).map((item) => item.info.id)), Effect.succeed([]), ), @@ -752,7 +763,7 @@ describe("session prompt queue", () => { const first = Effect.runPromise( KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_auto_sug_1"), + MessageID.make("msg_auto_sug_1"), Effect.gen(function* () { started.resolve() yield* Effect.promise(() => release.promise) @@ -767,7 +778,7 @@ describe("session prompt queue", () => { const second = Effect.runPromise( KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_auto_sug_2"), + MessageID.make("msg_auto_sug_2"), Effect.succeed("second" as const), Effect.succeed("second-cancelled" as const), ), diff --git a/packages/opencode/test/kilocode/suggestion/auto-dismiss.test.ts b/packages/opencode/test/kilocode/suggestion/auto-dismiss.test.ts index 7e7d8f0dab14..b5c95ec97ed9 100644 --- a/packages/opencode/test/kilocode/suggestion/auto-dismiss.test.ts +++ b/packages/opencode/test/kilocode/suggestion/auto-dismiss.test.ts @@ -24,7 +24,7 @@ describe("Suggestion.show auto-dismiss on queued followup", () => { const first = Effect.runPromise( KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_show_1"), + MessageID.make("msg_show_1"), Effect.gen(function* () { started.resolve() yield* Effect.promise(() => release.promise) @@ -39,7 +39,7 @@ describe("Suggestion.show auto-dismiss on queued followup", () => { const second = Effect.runPromise( KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_show_2"), + MessageID.make("msg_show_2"), Effect.succeed("second" as const), Effect.succeed("second-cancelled" as const), ), diff --git a/packages/opencode/test/kilocode/suggestion/suggestion.test.ts b/packages/opencode/test/kilocode/suggestion/suggestion.test.ts index 6810b14a1dd1..524cd17fa65f 100644 --- a/packages/opencode/test/kilocode/suggestion/suggestion.test.ts +++ b/packages/opencode/test/kilocode/suggestion/suggestion.test.ts @@ -6,6 +6,7 @@ import { localReviewUncommittedCommand } from "../../../src/kilocode/review/comm import { WithInstance } from "../../../src/project/with-instance" import { Suggestion } from "../../../src/kilocode/suggestion" import { resolvePrompt } from "../../../src/kilocode/suggestion/tool" +import { SessionID } from "../../../src/session/schema" import { tmpdir } from "../../fixture/fixture" afterEach(() => { @@ -314,7 +315,7 @@ describe("suggestion", () => { // Only B's suggestion remains const remaining = await Suggestion.list() expect(remaining).toHaveLength(1) - expect(remaining[0]?.sessionID).toBe("ses_b") + expect(remaining[0]?.sessionID).toBe(SessionID.make("ses_b")) // Clean up B await Suggestion.dismiss(remaining[0]!.id) @@ -329,7 +330,7 @@ describe("suggestion", () => { directory: tmp.path, fn: async () => { // Should not throw - await Suggestion.dismissAll("ses_nonexistent") + await Suggestion.dismissAll(SessionID.make("ses_nonexistent")) expect(await Suggestion.list()).toEqual([]) }, }) diff --git a/packages/opencode/test/kilocode/tool-encoding.test.ts b/packages/opencode/test/kilocode/tool-encoding.test.ts index 981554d434c1..5202a6d62648 100644 --- a/packages/opencode/test/kilocode/tool-encoding.test.ts +++ b/packages/opencode/test/kilocode/tool-encoding.test.ts @@ -28,7 +28,7 @@ import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test-encoding"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test-encoding"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts index e454fa7e42e9..a4142b44a4b5 100644 --- a/packages/opencode/test/lib/effect.ts +++ b/packages/opencode/test/lib/effect.ts @@ -4,6 +4,7 @@ import type * as Scope from "effect/Scope" import * as TestClock from "effect/testing/TestClock" import * as TestConsole from "effect/testing/TestConsole" import type { Config } from "@/config/config" +import { Reference } from "@/reference/reference" // kilocode_change import { TestInstance, withTmpdirInstance } from "../fixture/fixture" type Body = Effect.Effect | (() => Effect.Effect) @@ -107,5 +108,9 @@ const liveEnv = TestConsole.layer export const it = make(testEnv, liveEnv) -export const testEffect = (layer: Layer.Layer) => - make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) +// kilocode_change start +export const testEffect = (layer: Layer.Layer) => { + const full = Layer.merge(layer, Reference.defaultLayer) + return make(Layer.provideMerge(full, testEnv), Layer.provideMerge(full, liveEnv)) +} +// kilocode_change end diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 9276ffad1aa1..479af6ba5840 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,11 +1,15 @@ -import { afterAll, afterEach, test, expect } from "bun:test" // kilocode_change -import fs from "fs/promises" // kilocode_change +// kilocode_change start +import { afterAll, afterEach, test, expect } from "bun:test" +import fs from "fs/promises" +// kilocode_change end import os from "os" import path from "path" // kilocode_change import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Bus } from "../../src/bus" -import { Config } from "../../src/config/config" // kilocode_change -import { Global } from "@opencode-ai/core/global" // kilocode_change +// kilocode_change start +import { Config } from "../../src/config/config" +import { Global } from "@opencode-ai/core/global" +// kilocode_change end import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" @@ -840,8 +844,8 @@ it.live("allowEverything - session-scoped enable stays within one session", () = yield* waitForPending(1) yield* permission.allowEverything({ enable: true, - requestID: "permission_session_allow", - sessionID: "session_allowed", + requestID: PermissionID.make("permission_session_allow"), + sessionID: SessionID.make("session_allowed"), }) yield* Fiber.join(first) @@ -1097,7 +1101,6 @@ it.live("permission requests stay isolated by directory", () => const onePending = yield* waitForPending(1).pipe(runOne) const twoPending = yield* waitForPending(1).pipe(runTwo) - // kilocode_change start expect(onePending).toHaveLength(1) expect(twoPending).toHaveLength(1) expect(onePending[0].id).toBe(PermissionID.make("per_dir_a")) @@ -1108,7 +1111,6 @@ it.live("permission requests stay isolated by directory", () => yield* Fiber.await(a) yield* Fiber.await(b) - // kilocode_change end }), ) diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index 2a1580579d5d..c476c108b452 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -22,8 +22,9 @@ function run(fn: (svc: Project.Interface) => Effect.Effect) { ) } -function uid() { - return SessionID.make(crypto.randomUUID()) +function legacySessionID() { + // Global-session migration covers persisted IDs from before prefixed session IDs. + return crypto.randomUUID() as SessionID } function seed(opts: { id: SessionID; dir: string; project: ProjectID }) { @@ -73,7 +74,7 @@ describe("migrateFromGlobal", () => { expect(pre.id).toBe(ProjectID.global) // 2. Seed a session under "global" with matching directory - const id = uid() + const id = legacySessionID() seed({ id, dir: tmp.path, project: ProjectID.global }) // 3. Make a commit so the project gets a real ID @@ -100,7 +101,7 @@ describe("migrateFromGlobal", () => { // 3. Seed a session under "global" with matching directory. // This simulates a session created before git init that wasn't // present when the real project row was first created. - const id = uid() + const id = legacySessionID() seed({ id, dir: tmp.path, project: ProjectID.global }) // 4. Call fromDirectory again — project row already exists, @@ -121,7 +122,7 @@ describe("migrateFromGlobal", () => { // Legacy sessions may lack a directory value. // Without a matching origin directory, they should remain global. - const id = uid() + const id = legacySessionID() seed({ id, dir: "", project: ProjectID.global }) await run((svc) => svc.fromDirectory(tmp.path)) @@ -139,7 +140,7 @@ describe("migrateFromGlobal", () => { ensureGlobal() // Seed a session under "global" but for a DIFFERENT directory - const id = uid() + const id = legacySessionID() seed({ id, dir: "/some/other/dir", project: ProjectID.global }) await run((svc) => svc.fromDirectory(tmp.path)) diff --git a/packages/opencode/test/reference/reference.test.ts b/packages/opencode/test/reference/reference.test.ts new file mode 100644 index 000000000000..43427e4e66c3 --- /dev/null +++ b/packages/opencode/test/reference/reference.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { Effect, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Git } from "../../src/git" +import { Reference } from "../../src/reference/reference" +import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() +}) + +const it = testEffect( + Layer.mergeAll(AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, Reference.defaultLayer), +) + +const experimentalScout = (self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Flag.KILO_EXPERIMENTAL_SCOUT + Flag.KILO_EXPERIMENTAL_SCOUT = true + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + Flag.KILO_EXPERIMENTAL_SCOUT = previous + }), + ) + +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.KILO_REPO_CLONE_GITHUB_BASE_URL + process.env.KILO_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.KILO_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.KILO_REPO_CLONE_GITHUB_BASE_URL + }), + ) + +const git = Effect.fn("ReferenceTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + return stdout.trim() + }) +}) + +const waitForContent = ( + fs: AppFileSystem.Interface, + file: string, + content: string, + attempts = 50, +): Effect.Effect => + Effect.gen(function* () { + if ((yield* fs.readFileStringSafe(file)) === content) return + if (attempts <= 0) throw new Error(`timed out waiting for ${file}`) + yield* Effect.sleep("100 millis") + yield* waitForContent(fs, file, content, attempts - 1) + }) + +describe("reference", () => { + it.live("resolves local and git references", () => + Effect.gen(function* () { + const root = path.resolve("opencode-reference-root") + const local = Reference.resolve({ + name: "docs", + reference: { path: "../docs" }, + directory: path.join(root, "packages", "app"), + worktree: root, + }) + const repo = Reference.resolve({ + name: "effect", + reference: { repository: "Effect-TS/effect", branch: "main" }, + directory: path.join(root, "packages", "app"), + worktree: root, + }) + + expect(local.kind).toBe("local") + if (local.kind === "local") expect(local.path).toBe(path.resolve(root, "../docs")) + expect(repo.kind).toBe("git") + if (repo.kind === "git") { + expect(repo.repository).toBe("Effect-TS/effect") + expect(repo.branch).toBe("main") + expect(repo.path).toBe(path.join(Global.Path.repos, "github.com", "Effect-TS", "effect")) + } + }), + ) + + it.live("marks same-cache references with different branches invalid", () => + Effect.gen(function* () { + const root = path.resolve("opencode-reference-root") + const references = Reference.resolveAll({ + directory: root, + worktree: root, + references: { + main: { repository: "owner/repo", branch: "main" }, + dev: { repository: "github.com/owner/repo", branch: "dev" }, + alsoMain: { repository: "https://github.com/owner/repo", branch: "main" }, + }, + }) + + expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"]) + expect(references[1]?.kind).toBe("invalid") + if (references[1]?.kind === "invalid") { + expect(references[1].message).toContain("conflicts with @main") + expect(references[1].message).toContain("@dev requests dev") + } + }), + ) + + it.live("materializes configured git references during init", () => + experimentalScout( + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-test") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const reference = yield* Reference.Service + yield* githubBase( + `file://${remoteRoot}/`, + Effect.gen(function* () { + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n") + }), + ) + + expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true) + expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n") + + const resolved = yield* reference.get("docs") + expect(resolved?.kind).toBe("git") + if (resolved?.kind === "git") expect(resolved.path).toBe(cache) + }), + { + config: { + reference: { + docs: "opencode-reference-test/repo", + }, + }, + }, + ), + ), + ) + + it.live("refreshes configured git references on new instance init", () => + experimentalScout( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-refresh") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", + }, + }, + }, + ), + ) + + const branch = yield* git(source, ["branch", "--show-current"]) + yield* git(source, ["remote", "add", "origin", remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "update readme"]) + yield* git(source, ["push", "origin", `${branch}:${branch}`]) + + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", + }, + }, + }, + ), + ) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index 959c5fc3e851..cb25ad1432df 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -17,14 +17,16 @@ import { ToolListQuery, } from "../../src/server/routes/instance/httpapi/groups/experimental" import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { ListQuery as SessionListQuery, MessagesQuery, SessionPaths, } from "../../src/server/routes/instance/httpapi/groups/session" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" -import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query" +import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" @@ -33,7 +35,14 @@ const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES type Method = "get" | "post" | "put" | "delete" | "patch" type QuerySchema = { readonly fields: Record } -type OpenApiSchema = { readonly maximum?: number; readonly minimum?: number; readonly type?: string } +type OpenApiSchema = { + readonly anyOf?: readonly OpenApiSchema[] + readonly enum?: readonly string[] + readonly maximum?: number + readonly minimum?: number + readonly pattern?: string + readonly type?: string +} type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema } type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } @@ -68,6 +77,30 @@ const numericSdkQueryParams = [ { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, ] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> +const booleanSdkQueryParams = [ + { method: "get", path: ExperimentalPaths.session, name: "roots" }, + { method: "get", path: ExperimentalPaths.session, name: "archived" }, + { method: "get", path: SessionPaths.list, name: "roots" }, + { method: "get", path: "/api/session", name: "roots" }, +] satisfies Array<{ method: Method; path: string; name: string }> + +const queryParamPatterns = [ + { method: "get", path: SessionPaths.diff, name: "messageID", pattern: "^msg" }, +] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> + +const pathParamPatterns = [ + // kilocode_change start + { method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses.*" }, + { method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg.*" }, + { method: "patch", path: SessionPaths.updatePart, name: "partID", pattern: "^prt.*" }, + { method: "post", path: SessionPaths.permissions, name: "permissionID", pattern: "^per.*" }, + { method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per.*" }, + { method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que.*" }, + { method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty.*" }, + { method: "delete", path: WorkspacePaths.remove, name: "id", pattern: "^wrk.*" }, + // kilocode_change end +] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> + function app() { return Server.Default().app } @@ -98,6 +131,10 @@ function queryParameter(operation: OpenApiOperation | undefined, name: string) { return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name) } +function pathParameter(operation: OpenApiOperation | undefined, name: string) { + return (operation?.parameters ?? []).find((param) => param.in === "path" && param.name === name) +} + function assertAdvertisedQueryParamsAreRuntimeFields(input: { readonly method: Method readonly operation: OpenApiOperation | undefined @@ -148,7 +185,7 @@ describe("httpapi query schema drift", () => { ) it.effect( - "OpenAPI workspace query params are declared by runtime query schemas", + "OpenAPI query params are declared by runtime query schemas", Effect.sync(() => { const spec = OpenApi.fromApi(PublicApi) for (const route of openApiDriftRoutes) { @@ -161,7 +198,7 @@ describe("httpapi query schema drift", () => { ) it.effect( - "OpenAPI numeric query params preserve generated SDK call shapes", + "OpenAPI query and path schemas preserve compatibility metadata", Effect.sync(() => { const spec = OpenApi.fromApi(PublicApi) for (const expected of numericSdkQueryParams) { @@ -170,6 +207,24 @@ describe("httpapi query schema drift", () => { `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, ).toEqual(expected.schema) } + for (const expected of booleanSdkQueryParams) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual(QueryBooleanOpenApi) + } + for (const expected of queryParamPatterns) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual({ type: "string", pattern: expected.pattern }) + } + for (const expected of pathParamPatterns) { + expect( + pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual({ type: "string", pattern: expected.pattern }) + } }), ) diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts new file mode 100644 index 000000000000..32165290a640 --- /dev/null +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { eq } from "drizzle-orm" +import * as Database from "@/storage/db" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { Session } from "@/session/session" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" +import { MessageID, PartID } from "../../src/session/schema" +import { PartTable } from "@/session/session.sql" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +const withTmp = ( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) => + Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) + +async function seedCorruptStepFinishPart(directory: string) { + return WithInstance.provide({ + directory, + fn: () => + Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + // Schema.Finite still rejects NaN at encode — exact mirror of the + // corrupt row that broke the user's session in the OMO/Windows bug. + Database.use((db) => + db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, // drizzle's .set() can't narrow the discriminated union + }) + .where(eq(PartTable.id, partID)) + .run(), + ) + return info.id + }).pipe(Effect.provide(Session.defaultLayer)), + ), + }) +} + +describe("schema-rejection wire shape", () => { + it.live( + "Payload schema rejection returns NamedError-shaped JSON, not empty", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const res = yield* Effect.promise(async () => + Server.Default().app.request(SyncPaths.history, { + method: "POST", + headers: { "x-kilo-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: -1 }), + }), + ) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + expect(res.headers.get("content-type") ?? "").toContain("application/json") + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ + name: "BadRequest", + data: { kind: expect.stringMatching(/^(Body|Payload)$/) }, + }) + expect(parsed.data.message).toEqual(expect.any(String)) + expect(parsed.data.message.length).toBeGreaterThan(0) + }), + ), + ) + + it.live( + "Query schema rejection returns NamedError-shaped JSON", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + // /find/file?limit=999999 violates the limit constraint check. + const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } }) + }), + ), + ) + + it.live( + "rejected request body never echoes back unbounded — message is capped", + // Defense against DoS-amplification + secret-echo: Effect's Issue formatter + // dumps the rejected `actual` verbatim. A multi-MB invalid array would + // become a multi-MB 400 response and log line. Cap kicks in around 1KB. + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const huge = "X".repeat(50_000) + const res = yield* Effect.promise(async () => + Server.Default().app.request(SyncPaths.history, { + method: "POST", + headers: { "x-kilo-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: huge }), + }), + ) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + // 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB. + expect(body.length).toBeLessThan(2 * 1024) + const parsed = JSON.parse(body) + expect(parsed.data.message).not.toContain(huge) + }), + ), + ) + + it.live( + "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path)) + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + expect(res.headers.get("content-type") ?? "").toContain("application/json") + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } }) + // Field path in data.message — what made this PR worth shipping. + expect(parsed.data.message).toMatch(/output/) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 3022670e8814..ce2d57eb0465 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -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 }) => diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 0719beca1164..6b4fc891aee4 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -191,7 +191,7 @@ describe("HttpApi UI fallback", () => { }) test("keeps matched API routes ahead of the UI fallback", async () => { - const response = await Server.Default().app.request("/session/nope") + const response = await Server.Default().app.request("/session/ses_nope") expect(response.status).toBe(404) }) @@ -199,7 +199,7 @@ describe("HttpApi UI fallback", () => { test("requires server password for the web UI", async () => { Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true - const response = await uiApp({ password: "secret", username: "kilo" }).request("/") + const response = await uiApp({ password: "secret", username: "kilo" }).request("/") // kilocode_change expect(response.status).toBe(401) expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"') @@ -210,22 +210,28 @@ describe("HttpApi UI fallback", () => { const response = await uiApp({ password: "secret", + // kilocode_change start username: "kilo", }).request(`/?auth_token=${btoa("kilo:secret")}`) expect(response.status).toBe(404) expect(await response.json()).toEqual({ error: "Not Found" }) + // kilocode_change end }) test("accepts basic auth for the web UI", async () => { Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true + // kilocode_change start const response = await uiApp({ password: "secret", username: "kilo" }).request("/", { headers: { authorization: `Basic ${btoa("kilo:secret")}` }, + // kilocode_change end }) + // kilocode_change start expect(response.status).toBe(404) expect(await response.json()).toEqual({ error: "Not Found" }) + // kilocode_change end }) // Regression for #25698 (Ope): the browser fetches the PWA manifest and @@ -234,20 +240,22 @@ describe("HttpApi UI fallback", () => { // server returning 401 breaks PWA install. These specific public assets // should bypass auth. test("allows public PWA assets through auth without proxying", async () => { + // kilocode_change Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { const response = await uiApp({ password: "secret", - username: "kilo", + username: "kilo", // kilocode_change client: httpClient(new Response("ok")), }).request(path) - expect(response.status).toBe(404) + expect(response.status).toBe(404) // kilocode_change } }) test("allows web UI preflight without auth", async () => { const response = await app({ password: "secret", username: "kilo" }).request("/", { + // kilocode_change method: "OPTIONS", headers: { origin: "http://localhost:3000", diff --git a/packages/opencode/test/server/sdk-error-shape.test.ts b/packages/opencode/test/server/sdk-error-shape.test.ts index 31195dd0213a..fd259f97668e 100644 --- a/packages/opencode/test/server/sdk-error-shape.test.ts +++ b/packages/opencode/test/server/sdk-error-shape.test.ts @@ -52,23 +52,33 @@ describe("v2 SDK error shape", () => { }) }) - test("400 with empty body throws a real Error naming the status", async () => { + test("400 schema rejection: SDK extracts the field-level reason from the NamedError body", async () => { + // Canary for the #26631 wire shape. Asserts the contract end-to-end: + // server emits {name:"BadRequest", data:{message, kind}}, SDK's + // wrapClientError extracts .data.message into Error.message. If either + // side regresses (#26457 reverted because both layers were missing), + // this test fails before users see (empty response body). await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) const sdk = client(tmp.path) let caught: unknown try { - // POST /sync/history with `aggregate: -1` triggers schema validation - // that returns an empty 400 body (verified via plan-mode probe). - await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true }) + await sdk.sync.history.list({ body: { aggregate: -1 } as any }, { throwOnError: true }) } catch (e) { caught = e } expect(caught).toBeInstanceOf(Error) const err = caught as Error - const cause = err.cause as { status?: number } - expect(err.message.length).toBeGreaterThan(0) + const cause = err.cause as { body?: any; status?: number } expect(cause.status).toBe(400) + expect(cause.body).toMatchObject({ + name: "BadRequest", + data: { kind: expect.stringMatching(/^(Body|Payload)$/) }, + }) + expect(typeof cause.body.data.message).toBe("string") + expect(cause.body.data.message.length).toBeGreaterThan(0) + // Whatever the server put in data.message must be what the user sees. + expect(err.message).toBe(cause.body.data.message) }) }) diff --git a/packages/opencode/test/server/sdk-v1-smoke.test.ts b/packages/opencode/test/server/sdk-v1-smoke.test.ts new file mode 100644 index 000000000000..5b91f3f2c4c9 --- /dev/null +++ b/packages/opencode/test/server/sdk-v1-smoke.test.ts @@ -0,0 +1,60 @@ +// Smoke test: v1 SDK (the plugin contract) can actually reach core endpoints +// against the current server. v1 generation has been frozen since #5216 +// (2025-12-07) so types may be stale, but runtime calls should still work +// for endpoints the v1 SDK was generated against. +import { afterEach, describe, expect, test } from "bun:test" +import { createKiloClient } from "@kilocode/sdk" +import { Server } from "../../src/server/server" +import { tmpdir, disposeAllInstances } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" +import * as Log from "@opencode-ai/core/util/log" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function client(directory: string) { + return createKiloClient({ + baseUrl: "http://test", + directory, + fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch, + }) +} + +describe("v1 SDK runtime smoke", () => { + test("session.list reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.session.list() + expect(result.error).toBeUndefined() + expect(Array.isArray(result.data)).toBe(true) + }) + + test("path.get reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.path.get() + expect(result.error).toBeUndefined() + expect(result.data).toBeDefined() + }) + + test("config.get reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.config.get() + expect(result.error).toBeUndefined() + expect(result.data).toBeDefined() + }) + + test("session 404: result-tuple path returns the error body", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.session.get({ path: { id: "ses_no_such" } as never }) + expect(result.error).toBeDefined() + // wire body for 404 is NamedError-shaped + expect(result.error).toMatchObject({ name: "NotFoundError" }) + }) +}) diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts index a921ae2774c5..d327850fb5ee 100644 --- a/packages/opencode/test/server/workspace-routing.test.ts +++ b/packages/opencode/test/server/workspace-routing.test.ts @@ -46,6 +46,13 @@ describe("getWorkspaceRouteSessionID", () => { expect(getWorkspaceRouteSessionID(url)).toBeNull() }) + // kilocode_change start + test("returns null for Kilo's /session/viewed route", () => { + const url = new URL("http://localhost/session/viewed") + expect(getWorkspaceRouteSessionID(url)).toBeNull() + }) + // kilocode_change end + test("returns null for non-session paths", () => { const url = new URL("http://localhost/config") expect(getWorkspaceRouteSessionID(url)).toBeNull() diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c49e70af3313..f734974f44d1 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1,20 +1,18 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { APICallError } from "ai" -import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import * as Stream from "effect/Stream" -import z from "zod" import { Bus } from "../../src/bus" import { Config } from "@/config/config" +import { Image } from "@/image/image" import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" -import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -29,29 +27,10 @@ import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestConfig } from "../fixture/config" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) -} - -const svc = { - ...SessionNs, - create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) - }, - messages(input: z.output) { - return run(SessionNs.Service.use((svc) => svc.messages(input))) - }, - updateMessage(msg: T) { - return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) - }, - updatePart(part: T) { - return run(SessionNs.Service.use((svc) => svc.updatePart(part))) - }, -} - const summary = Layer.succeed( SessionSummary.Service, SessionSummary.Service.of({ @@ -102,87 +81,109 @@ function createModel(opts: { const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) }) -async function user(sessionID: SessionID, text: string) { - const msg = await svc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "build", - model: ref, - time: { created: Date.now() }, - }) - await svc.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID, - type: "text", - text, +function createUserMessage(sessionID: SessionID, text: string) { + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg }) - return msg } -async function assistant(sessionID: SessionID, parentID: MessageID, root: string) { - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID, - mode: "build", - agent: "build", - path: { cwd: root, root }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ref.modelID, - providerID: ref.providerID, - parentID, - time: { created: Date.now() }, - finish: "end_turn", - } - await svc.updateMessage(msg) - return msg +function createAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string) { + return SessionNs.Service.use((ssn) => + ssn.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "build", + agent: "build", + path: { cwd: root, root }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + time: { created: Date.now() }, + finish: "end_turn", + }), + ) } -async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) { - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID, - mode: "compaction", - agent: "compaction", - path: { cwd: root, root }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ref.modelID, - providerID: ref.providerID, - parentID, - summary: true, - time: { created: Date.now() }, - finish: "end_turn", - } - await svc.updateMessage(msg) - await svc.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID, - type: "text", - text, - }) - return msg +function createSummaryAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string, text: string) { + return SessionNs.Service.use((ssn) => + Effect.gen(function* () { + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "compaction", + agent: "compaction", + path: { cwd: root, root }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + summary: true, + time: { created: Date.now() }, + finish: "end_turn", + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg + }), + ) } -async function lastCompactionPart(sessionID: SessionID) { - return (await svc.messages({ sessionID })) - .at(-2) - ?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction") +function createCompactionMarker(sessionID: SessionID) { + return SessionNs.Service.use((ssn) => + Effect.gen(function* () { + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + model: ref, + sessionID, + agent: "build", + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: msg.sessionID, + type: "compaction", + auto: false, + }) + }), + ) } function fake( @@ -216,33 +217,14 @@ function cfg(compaction?: Config.Info["compaction"]) { }) } -function runtime( - result: "continue" | "compact", - plugin = Plugin.defaultLayer, - provider = ProviderTest.fake(), - config = Config.defaultLayer, -) { - const bus = Bus.layer - return ManagedRuntime.make( - Layer.mergeAll(SessionCompaction.layer, bus).pipe( - Layer.provide(provider.layer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(layer(result)), - Layer.provide(Agent.defaultLayer), - Layer.provide(plugin), - Layer.provide(bus), - Layer.provide(config), - ), - ) -} - const deps = Layer.mergeAll( - ProviderTest.fake().layer, + wide().layer, layer("continue"), Agent.defaultLayer, Plugin.defaultLayer, Bus.layer, Config.defaultLayer, + SyncEvent.defaultLayer, ) const env = Layer.mergeAll( @@ -253,6 +235,58 @@ const env = Layer.mergeAll( const it = testEffect(env) +const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer) +const itCompaction = testEffect(compactionEnv) + +type CompactionProcessOptions = { + result?: "continue" | "compact" + llm?: Layer.Layer + plugin?: Layer.Layer + provider?: ReturnType + config?: Layer.Layer +} + +function withCompaction(options?: CompactionProcessOptions) { + return Effect.provide(compactionProcessLayer(options)) +} + +function compactionProcessLayer(options?: CompactionProcessOptions) { + const bus = Bus.layer + const status = SessionStatus.layer.pipe(Layer.provide(bus)) + const processor = options?.llm + ? SessionProcessorModule.SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provide(status), + ) + : layer(options?.result ?? "continue") + return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( + Layer.provide(SessionNs.defaultLayer), + Layer.provide((options?.provider ?? wide()).layer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(options?.llm ?? LLM.defaultLayer), + Layer.provide(Permission.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(options?.plugin ?? Plugin.defaultLayer), + Layer.provide(status), + Layer.provide(bus), + Layer.provide(options?.config ?? Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + ) +} + +function createSummaryCompaction(sessionID: SessionID) { + return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false }) +} + +function readCompactionPart(sessionID: SessionID) { + return SessionNs.Service.use((ssn) => ssn.messages({ sessionID })).pipe( + Effect.map((messages) => + messages.at(-2)?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction"), + ), + ) +} + function llm() { const queue: Array< Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream) @@ -275,26 +309,6 @@ function llm() { } } -function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake(), config = Config.defaultLayer) { - const bus = Bus.layer - const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) - return ManagedRuntime.make( - Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( - Layer.provide(provider.layer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(layer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(status), - Layer.provide(bus), - Layer.provide(config), - ), - ) -} - function reply( text: string, capture?: (input: LLM.StreamInput) => void, @@ -350,23 +364,14 @@ function reply( } } -function wait(ms = 50) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function defer() { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -function plugin(ready: ReturnType) { +function plugin(ready: Deferred.Deferred) { return Layer.mock(Plugin.Service)({ trigger: (name: Name, _input: Input, output: Output) => { if (name !== "experimental.session.compacting") return Effect.succeed(output) - return Effect.sync(() => ready.resolve()).pipe(Effect.andThen(Effect.never), Effect.as(output)) + return Effect.sync(() => Deferred.doneUnsafe(ready, Effect.void)).pipe( + Effect.andThen(Effect.never), + Effect.as(output), + ) }, list: () => Effect.succeed([]), init: () => Effect.void, @@ -801,319 +806,216 @@ describe("session.compaction.prune", () => { }) describe("session.compaction.process", () => { - test("throws when parent is not a user message", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const reply = await assistant(session.id, msg.id, tmp.path) - const rt = runtime("continue") - try { - const msgs = await svc.messages({ sessionID: session.id }) - await expect( - rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: reply.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ), - ).rejects.toThrow(`Compaction parent must be a user message: ${reply.id}`) - } finally { - await rt.dispose() - } - }, - }) - }) - - test("publishes compacted event on continue", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const msgs = await svc.messages({ sessionID: session.id }) - const done = defer() - let seen = false - const rt = runtime("continue", Plugin.defaultLayer, wide()) - let unsub: (() => void) | undefined - try { - unsub = await rt.runPromise( - Bus.Service.use((svc) => - svc.subscribeCallback(SessionCompaction.Event.Compacted, (evt) => { - if (evt.properties.sessionID !== session.id) return - seen = true - done.resolve() - }), - ), - ) - - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - await Promise.race([ - done.promise, - wait(500).then(() => { - throw new Error("timed out waiting for compacted event") - }), - ]) - expect(result).toBe("continue") - expect(seen).toBe(true) - } finally { - unsub?.() - await rt.dispose() + it.instance( + "throws when parent is not a user message", + Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const reply = yield* createAssistantMessage(session.id, msg.id, test.directory) + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const exit = yield* Effect.exit( + SessionCompaction.use.process({ + parentID: reply.id, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Error) + if (error instanceof Error) { + expect(error.message).toContain(`Compaction parent must be a user message: ${reply.id}`) } - }, - }) - }) + } + }), + ) - test("marks summary message as errored on compact result", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = runtime("compact", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + it.instance( + "publishes compacted event on continue", + Effect.gen(function* () { + const bus = yield* Bus.Service + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + const done = yield* Deferred.make() + let seen = false + const unsub = yield* bus.subscribeCallback(SessionCompaction.Event.Compacted, (evt) => { + if (evt.properties.sessionID !== session.id) return + seen = true + Deferred.doneUnsafe(done, Effect.void) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsub)) - const summary = (await svc.messages({ sessionID: session.id })).find( - (msg) => msg.info.role === "assistant" && msg.info.summary, - ) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) - expect(result).toBe("stop") - expect(summary?.info.role).toBe("assistant") - if (summary?.info.role === "assistant") { - expect(summary.info.finish).toBe("error") - expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") - } - } finally { - await rt.dispose() - } - }, - }) - }) + yield* Deferred.await(done).pipe(Effect.timeout("500 millis")) + expect(result).toBe("continue") + expect(seen).toBe(true) + }), + ) - test("adds synthetic continue prompt when auto is enabled", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = runtime("continue", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - }), - ), - ) + itCompaction.instance( + "marks summary message as errored on compact result", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) - const all = await svc.messages({ sessionID: session.id }) - const last = all.at(-1) + const summary = (yield* ssn.messages({ sessionID: session.id })).find( + (msg) => msg.info.role === "assistant" && msg.info.summary, + ) - expect(result).toBe("continue") - expect(last?.info.role).toBe("user") - expect(last?.parts[0]).toMatchObject({ - type: "text", - synthetic: true, - metadata: { compaction_continue: true }, - }) - if (last?.parts[0]?.type === "text") { - expect(last.parts[0].text).toContain("Continue if you have next steps") - } - } finally { - await rt.dispose() - } - }, - }) - }) + expect(result).toBe("stop") + expect(summary?.info.role).toBe("assistant") + if (summary?.info.role === "assistant") { + expect(summary.info.finish).toBe("error") + expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") + } + }).pipe(withCompaction({ result: "compact" })), + ) - test("persists tail_start_id for retained recent turns", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "first") - const keep = await user(session.id, "second") - await user(session.id, "third") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + it.instance( + "adds synthetic continue prompt when auto is enabled", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + }) - const rt = runtime( - "continue", - Plugin.defaultLayer, - wide(), - cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }), - ) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const all = yield* ssn.messages({ sessionID: session.id }) + const last = all.at(-1) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - } finally { - await rt.dispose() - } - }, - }) - }) + expect(result).toBe("continue") + expect(last?.info.role).toBe("user") + expect(last?.parts[0]).toMatchObject({ + type: "text", + synthetic: true, + metadata: { compaction_continue: true }, + }) + if (last?.parts[0]?.type === "text") { + expect(last.parts[0].text).toContain("Continue if you have next steps") + } + }), + ) - test("shrinks retained tail to fit preserve token budget", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "first") - await user(session.id, "x".repeat(2_000)) - const keep = await user(session.id, "tiny") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + itCompaction.instance( + "persists tail_start_id for retained recent turns", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + const keep = yield* createUserMessage(session.id, "second") + yield* createUserMessage(session.id, "third") + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) - const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 100 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })), + ) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - } finally { - await rt.dispose() - } - }, - }) - }) + itCompaction.instance( + "shrinks retained tail to fit preserve token budget", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + yield* createUserMessage(session.id, "x".repeat(2_000)) + const keep = yield* createUserMessage(session.id, "tiny") + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) - test("falls back to full summary when even one recent turn exceeds preserve token budget", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "first") - await user(session.id, "y".repeat(2_000)) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })), + ) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 20 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() - expect(captured).toContain("yyyy") - } finally { - await rt.dispose() - } - }, - }) - }) + itCompaction.instance( + "falls back to full summary when even one recent turn exceeds preserve token budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + yield* createUserMessage(session.id, "y".repeat(2_000)) + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("yyyy") + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) })) + }, + { git: true }, + ) - test("falls back to full summary when retained tail media exceeds preserve token budget", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older") - const recent = await user(session.id, "recent image turn") - await svc.updatePart({ + itCompaction.instance( + "falls back to full summary when retained tail media exceeds preserve token budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + const recent = yield* createUserMessage(session.id, "recent image turn") + yield* ssn.updatePart({ id: PartID.ascending(), messageID: recent.id, sessionID: session.id, @@ -1122,746 +1024,496 @@ describe("session.compaction.process", () => { filename: "big.png", url: `data:image/png;base64,${"a".repeat(4_000)}`, }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) - - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() - expect(captured).toContain("recent image turn") - expect(captured).toContain("Attached image/png: big.png") - } finally { - await rt.dispose() - } - }, - }) - }) + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("recent image turn") + expect(captured).toContain("Attached image/png: big.png") + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) + }, + { git: true }, + ) - test("retains a split turn suffix when a later message fits the preserve token budget", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older") - const recent = await user(session.id, "recent turn") - const large = await assistant(session.id, recent.id, tmp.path) - await svc.updatePart({ + itCompaction.instance( + "retains a split turn suffix when a later message fits the preserve token budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + const recent = yield* createUserMessage(session.id, "recent turn") + const large = yield* createAssistantMessage(session.id, recent.id, test.directory) + yield* ssn.updatePart({ id: PartID.ascending(), messageID: large.id, sessionID: session.id, type: "text", text: "z".repeat(2_000), }) - const keep = await assistant(session.id, recent.id, tmp.path) - await svc.updatePart({ + const keep = yield* createAssistantMessage(session.id, recent.id, test.directory) + yield* ssn.updatePart({ id: PartID.ascending(), messageID: keep.id, sessionID: session.id, type: "text", text: "keep tail", }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + expect(captured).toContain("zzzz") + expect(captured).not.toContain("keep tail") + + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) + expect(filtered[1]?.info.role).toBe("assistant") + expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) + expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) + }, + { git: true }, + ) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - expect(captured).toContain("zzzz") - expect(captured).not.toContain("keep tail") - - const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) - expect(filtered[1]?.info.role).toBe("assistant") - expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) - expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) - } finally { - await rt.dispose() - } - }, - }) - }) + itCompaction.instance( + "allows plugins to disable synthetic continue prompt", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + }) - test("allows plugins to disable synthetic continue prompt", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = runtime("continue", autocontinue(false), wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - }), + const all = yield* ssn.messages({ sessionID: session.id }) + const last = all.at(-1) + + expect(result).toBe("continue") + expect(last?.info.role).toBe("assistant") + expect( + all.some( + (msg) => + msg.info.role === "user" && + msg.parts.some( + (part) => part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"), ), - ) - - const all = await svc.messages({ sessionID: session.id }) - const last = all.at(-1) - - expect(result).toBe("continue") - expect(last?.info.role).toBe("assistant") - expect( - all.some( - (msg) => - msg.info.role === "user" && - msg.parts.some( - (part) => - part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"), - ), - ), - ).toBe(false) - } finally { - await rt.dispose() - } - }, - }) - }) + ), + ).toBe(false) + }).pipe(withCompaction({ plugin: autocontinue(false) })), + ) - test("replays the prior user turn on overflow when earlier context exists", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "root") - const replay = await user(session.id, "image") - await svc.updatePart({ - id: PartID.ascending(), - messageID: replay.id, - sessionID: session.id, - type: "file", - mime: "image/png", - filename: "cat.png", - url: "https://example.com/cat.png", - }) - const msg = await user(session.id, "current") - const rt = runtime("continue", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - overflow: true, - }), - ), - ) - - const last = (await svc.messages({ sessionID: session.id })).at(-1) - - expect(result).toBe("continue") - expect(last?.info.role).toBe("user") - expect(last?.parts.some((part) => part.type === "file")).toBe(false) - expect( - last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")), - ).toBe(true) - } finally { - await rt.dispose() - } - }, - }) - }) + it.instance( + "replays the prior user turn on overflow when earlier context exists", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "root") + const replay = yield* createUserMessage(session.id, "image") + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: replay.id, + sessionID: session.id, + type: "file", + mime: "image/png", + filename: "cat.png", + url: "https://example.com/cat.png", + }) + const msg = yield* createUserMessage(session.id, "current") + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + overflow: true, + }) - test("falls back to overflow guidance when no replayable turn exists", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "earlier") - const msg = await user(session.id, "current") - - const rt = runtime("continue", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - overflow: true, - }), - ), - ) + const last = (yield* ssn.messages({ sessionID: session.id })).at(-1) - const last = (await svc.messages({ sessionID: session.id })).at(-1) + expect(result).toBe("continue") + expect(last?.info.role).toBe("user") + expect(last?.parts.some((part) => part.type === "file")).toBe(false) + expect( + last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")), + ).toBe(true) + }), + ) - expect(result).toBe("continue") - expect(last?.info.role).toBe("user") - if (last?.parts[0]?.type === "text") { - expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit") - } - } finally { - await rt.dispose() - } - }, - }) - }) + it.instance( + "falls back to overflow guidance when no replayable turn exists", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "earlier") + const msg = yield* createUserMessage(session.id, "current") + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + overflow: true, + }) - test("stops quickly when aborted during retry backoff", async () => { - const stub = llm() - const ready = defer() - stub.push( - Stream.fromAsyncIterable( - { - async *[Symbol.asyncIterator]() { - yield { type: "start" } as LLM.Event - throw new APICallError({ - message: "boom", - url: "https://example.com/v1/chat/completions", - requestBodyValues: {}, - statusCode: 503, - responseHeaders: { "retry-after-ms": "10000" }, - responseBody: '{"error":"boom"}', - isRetryable: true, - }) - }, - }, - (err) => err, - ), - ) + const last = (yield* ssn.messages({ sessionID: session.id })).at(-1) - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const msgs = await svc.messages({ sessionID: session.id }) - const abort = new AbortController() - const rt = liveRuntime(stub.layer, wide()) - let off: (() => void) | undefined - let run: Promise<"continue" | "stop"> | undefined - try { - off = await rt.runPromise( - Bus.Service.use((svc) => - svc.subscribeCallback(SessionStatus.Event.Status, (evt) => { - if (evt.properties.sessionID !== session.id) return - if (evt.properties.status.type !== "retry") return - ready.resolve() - }), - ), - ) - - run = rt - .runPromiseExit( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - { signal: abort.signal }, - ) - .then((exit) => { - if (Exit.isFailure(exit)) { - if (Cause.hasInterrupts(exit.cause) && abort.signal.aborted) return "stop" - throw Cause.squash(exit.cause) - } - return exit.value - }) + expect(result).toBe("continue") + expect(last?.info.role).toBe("user") + if (last?.parts[0]?.type === "text") { + expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit") + } + }), + ) - await Promise.race([ - ready.promise, - wait(5000).then(() => { - // kilocode_change - throw new Error("timed out waiting for retry status") - }), - ]) - - const start = Date.now() - abort.abort() - // kilocode_change start - const result = await Promise.race([ - run.then((value) => ({ kind: "done" as const, value, ms: Date.now() - start })), - wait(2000).then(() => ({ kind: "timeout" as const })), - ]) - - expect(result.kind).toBe("done") - if (result.kind === "done") { - expect(result.value).toBe("stop") - expect(result.ms).toBeLessThan(2000) - } - // kilocode_change end - } finally { - off?.() - abort.abort() - await rt.dispose() - await run?.catch(() => undefined) - } - }, - }) - }) + itCompaction.instance( + "stops quickly when aborted during retry backoff", + () => { + const stub = llm() + stub.push( + Stream.fromAsyncIterable( + { + async *[Symbol.asyncIterator]() { + yield { type: "start" } as LLM.Event + throw new APICallError({ + message: "boom", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 503, + responseHeaders: { "retry-after-ms": "10000" }, + responseBody: '{"error":"boom"}', + isRetryable: true, + }) + }, + }, + (err) => err, + ), + ) - test("does not leave a summary assistant when aborted before processor setup", async () => { - const ready = defer() - - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const msgs = await svc.messages({ sessionID: session.id }) - const abort = new AbortController() - const rt = runtime("continue", plugin(ready), wide()) - let run: Promise<"continue" | "stop"> | undefined - try { - run = rt - .runPromiseExit( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - { signal: abort.signal }, - ) - .then((exit) => { - if (Exit.isFailure(exit)) { - if (Cause.hasInterrupts(exit.cause) && abort.signal.aborted) return "stop" - throw Cause.squash(exit.cause) - } - return exit.value - }) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const bus = yield* Bus.Service + const ready = yield* Deferred.make() + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => { + if (evt.properties.sessionID !== session.id) return + if (evt.properties.status.type !== "retry") return + Deferred.doneUnsafe(ready, Effect.void) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) - await Promise.race([ - ready.promise, - wait(1000).then(() => { - throw new Error("timed out waiting for compaction hook") - }), - ]) + const fiber = yield* SessionCompaction.use + .process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) + .pipe(Effect.forkChild) - abort.abort() - expect(await run).toBe("stop") + yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) + const start = Date.now() + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) - const all = await svc.messages({ sessionID: session.id }) - expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false) - } finally { - abort.abort() - await rt.dispose() - await run?.catch(() => undefined) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.hasInterrupts(exit.cause)).toBe(true) + expect(Date.now() - start).toBeLessThan(250) } - }, - }) - }) - - test("does not allow tool calls while generating the summary", async () => { - const stub = llm() - stub.push( - Stream.make( - { type: "start" } satisfies LLM.Event, - { type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event, - { type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event, - { - type: "finish-step", - finishReason: "tool-calls", - rawFinishReason: "tool_calls", - response: { id: "res", modelId: "test-model", timestamp: new Date() }, - providerMetadata: undefined, - usage: { - inputTokens: 1, - outputTokens: 1, - totalTokens: 2, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, - } satisfies LLM.Event, - { - type: "finish", - finishReason: "tool-calls", - rawFinishReason: "tool_calls", - totalUsage: { - inputTokens: 1, - outputTokens: 1, - totalTokens: 2, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, - } satisfies LLM.Event, - ), - ) - - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = liveRuntime(stub.layer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) - const summary = (await svc.messages({ sessionID: session.id })).find( - (item) => item.info.role === "assistant" && item.info.summary, - ) + itCompaction.instance( + "does not leave a summary assistant when aborted before processor setup", + () => + Effect.gen(function* () { + const ready = yield* Deferred.make() + return yield* Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + const fiber = yield* SessionCompaction.use + .process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) + .pipe(Effect.forkChild) - expect(summary?.info.role).toBe("assistant") - expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) - } finally { - await rt.dispose() - } - }, - }) - }) + yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) + const all = yield* ssn.messages({ sessionID: session.id }) - test("summarizes only the head while keeping recent tail out of summary input", async () => { - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true) + expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false) + }).pipe(withCompaction({ plugin: plugin(ready) })) }), - ) + { git: true }, + ) - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older context") - await user(session.id, "keep this turn") - await user(session.id, "and this one too") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + itCompaction.instance( + "does not allow tool calls while generating the summary", + () => { + const stub = llm() + stub.push( + Stream.make( + { type: "start" } satisfies LLM.Event, + { type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event, + { type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event, + { + type: "finish-step", + finishReason: "tool-calls", + rawFinishReason: "tool_calls", + response: { id: "res", modelId: "test-model", timestamp: new Date() }, + providerMetadata: undefined, + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }, + } satisfies LLM.Event, + { + type: "finish", + finishReason: "tool-calls", + rawFinishReason: "tool_calls", + totalUsage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }, + } satisfies LLM.Event, + ), + ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: false }) - const rt = liveRuntime(stub.layer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - expect(captured).toContain("older context") - expect(captured).not.toContain("keep this turn") - expect(captured).not.toContain("and this one too") - expect(captured).not.toContain("What did we do so far?") - } finally { - await rt.dispose() - } - }, - }) - }) + const summary = (yield* ssn.messages({ sessionID: session.id })).find( + (item) => item.info.role === "assistant" && item.info.summary, + ) - test("anchors repeated compactions with the previous summary", async () => { - const stub = llm() - let captured = "" - stub.push(reply("summary one")) - stub.push( - reply("summary two", (input) => { - captured = JSON.stringify(input.messages) - }), - ) + expect(summary?.info.role).toBe("assistant") + expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older context") - await user(session.id, "keep this turn") - await SessionCompaction.create({ + itCompaction.instance( + "summarizes only the head while keeping recent tail out of summary input", + () => { + const stub = llm() + let captured = "" + stub.push( + reply("summary", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older context") + yield* createUserMessage(session.id, "keep this turn") + yield* createUserMessage(session.id, "and this one too") + yield* createCompactionMarker(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, sessionID: session.id, - agent: "build", - model: ref, auto: false, }) - const rt = liveRuntime(stub.layer, wide()) - try { - let msgs = await svc.messages({ sessionID: session.id }) - let parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + expect(captured).toContain("older context") + expect(captured).not.toContain("keep this turn") + expect(captured).not.toContain("and this one too") + expect(captured).not.toContain("What did we do so far?") + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) - await user(session.id, "latest turn") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + itCompaction.instance( + "anchors repeated compactions with the previous summary", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary one")) + stub.push( + reply("summary two", (input) => { + captured = JSON.stringify(input.messages) + }), + ) - msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) - parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - expect(captured).toContain("") - expect(captured).toContain("summary one") - expect(captured.match(/summary one/g)?.length).toBe(1) - expect(captured).toContain("## Constraints & Preferences") - expect(captured).toContain("## Progress") - } finally { - await rt.dispose() - } - }, - }) - }) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older context") + yield* createUserMessage(session.id, "keep this turn") + yield* createCompactionMarker(session.id) + + let msgs = yield* ssn.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + yield* createUserMessage(session.id, "latest turn") + yield* createCompactionMarker(session.id) + + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + expect(captured).toContain("") + expect(captured).toContain("summary one") + expect(captured.match(/summary one/g)?.length).toBe(1) + expect(captured).toContain("## Constraints & Preferences") + expect(captured).toContain("## Progress") + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) - test("keeps recent pre-compaction turns across repeated compactions", async () => { + itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => { const stub = llm() stub.push(reply("summary one")) stub.push(reply("summary two")) - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const u1 = await user(session.id, "one") - const u2 = await user(session.id, "two") - const u3 = await user(session.id, "three") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) - - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 })) - try { - let msgs = await svc.messages({ sessionID: session.id }) - let parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const u4 = await user(session.id, "four") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) - msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) - parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - const ids = filtered.map((msg) => msg.info.id) - - expect(ids).not.toContain(u1.id) - expect(ids).not.toContain(u2.id) - expect(ids).toContain(u3.id) - expect(ids).toContain(u4.id) - expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) - expect( - filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), - ).toBe(true) - } finally { - await rt.dispose() - } - }, - }) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const u1 = yield* createUserMessage(session.id, "one") + const u2 = yield* createUserMessage(session.id, "two") + const u3 = yield* createUserMessage(session.id, "three") + yield* createCompactionMarker(session.id) + + let msgs = yield* ssn.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + const u4 = yield* createUserMessage(session.id, "four") + yield* createCompactionMarker(session.id) + + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + const ids = filtered.map((msg) => msg.info.id) + + expect(ids).not.toContain(u1.id) + expect(ids).not.toContain(u2.id) + expect(ids).toContain(u3.id) + expect(ids).toContain(u4.id) + expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) + expect( + filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), + ).toBe(true) + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) }) - test("ignores previous summaries when sizing the retained tail", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older") - const keep = await user(session.id, "keep this turn") - const keepReply = await assistant(session.id, keep.id, tmp.path) - await svc.updatePart({ - id: PartID.ascending(), - messageID: keepReply.id, - sessionID: session.id, - type: "text", - text: "keep reply", - }) - - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) - const firstCompaction = (await svc.messages({ sessionID: session.id })).at(-1)?.info.id - expect(firstCompaction).toBeTruthy() - await summaryAssistant(session.id, firstCompaction!, tmp.path, "summary ".repeat(800)) - - const recent = await user(session.id, "recent turn") - const recentReply = await assistant(session.id, recent.id, tmp.path) - await svc.updatePart({ - id: PartID.ascending(), - messageID: recentReply.id, - sessionID: session.id, - type: "text", - text: "recent reply", - }) + itCompaction.instance( + "ignores previous summaries when sizing the retained tail", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const test = yield* TestInstance + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + const keep = yield* createUserMessage(session.id, "keep this turn") + const keepReply = yield* createAssistantMessage(session.id, keep.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: keepReply.id, + sessionID: session.id, + type: "text", + text: "keep reply", + }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + yield* createCompactionMarker(session.id) + const firstCompaction = (yield* ssn.messages({ sessionID: session.id })).at(-1)?.info.id + expect(firstCompaction).toBeTruthy() + yield* createSummaryAssistantMessage(session.id, firstCompaction!, test.directory, "summary ".repeat(800)) + + const recent = yield* createUserMessage(session.id, "recent turn") + const recentReply = yield* createAssistantMessage(session.id, recent.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: recentReply.id, + sessionID: session.id, + type: "text", + text: "recent reply", + }) - const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 500 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + yield* createCompactionMarker(session.id) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - } finally { - await rt.dispose() - } - }, - }) - }) + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })), + ) }) describe("util.token.estimate", () => { diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 3bb38c87867a..5d40933954eb 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -61,7 +61,7 @@ const tmpWithFiles = (files: Record) => function loaded(filepath: string): MessageV2.WithParts[] { const sessionID = SessionID.make("session-loaded-1") - const messageID = MessageID.make("message-loaded-1") + const messageID = MessageID.make("msg_message-loaded-1") return [ { @@ -78,7 +78,7 @@ function loaded(filepath: string): MessageV2.WithParts[] { }, parts: [ { - id: PartID.make("part-loaded-1"), + id: PartID.make("prt_part-loaded-1"), messageID, sessionID, type: "tool", @@ -106,7 +106,7 @@ describe("Instruction.resolve", () => { const system = yield* svc.systemPaths() expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true) - const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1")) + const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("msg_message-test-1")) expect(results).toEqual([]) }), ), @@ -122,7 +122,7 @@ describe("Instruction.resolve", () => { const results = yield* svc.resolve( [], path.join(dir, "subdir", "nested", "file.ts"), - MessageID.make("message-test-2"), + MessageID.make("msg_message-test-2"), ) expect(results.length).toBe(1) expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) @@ -138,7 +138,7 @@ describe("Instruction.resolve", () => { const system = yield* svc.systemPaths() expect(system.has(filepath)).toBe(false) - const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3")) + const results = yield* svc.resolve([], filepath, MessageID.make("msg_message-test-3")) expect(results).toEqual([]) }), ), @@ -149,7 +149,7 @@ describe("Instruction.resolve", () => { Effect.gen(function* () { const svc = yield* Instruction.Service const filepath = path.join(dir, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-1") + const id = MessageID.make("msg_message-claim-1") const first = yield* svc.resolve([], filepath, id) const second = yield* svc.resolve([], filepath, id) @@ -166,7 +166,7 @@ describe("Instruction.resolve", () => { Effect.gen(function* () { const svc = yield* Instruction.Service const filepath = path.join(dir, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-2") + const id = MessageID.make("msg_message-claim-2") const first = yield* svc.resolve([], filepath, id) yield* svc.clear(id) @@ -185,7 +185,7 @@ describe("Instruction.resolve", () => { const svc = yield* Instruction.Service const agents = path.join(dir, "subdir", "AGENTS.md") const filepath = path.join(dir, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-3") + const id = MessageID.make("msg_message-claim-3") const results = yield* svc.resolve(loaded(agents), filepath, id) expect(results).toEqual([]) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index eb95ef105d27..60ae3d452cde 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -324,7 +324,7 @@ describe("session.llm.stream", () => { await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", + $schema: "https://app.kilo.ai/config.json", // kilocode_change enabled_providers: [providerID], provider: { [providerID]: { @@ -354,7 +354,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-1"), + id: MessageID.make("msg_user-1"), sessionID, role: "user", time: { created: Date.now() }, @@ -439,7 +439,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info const user = { - id: MessageID.make("user-service-abort"), + id: MessageID.make("msg_user-service-abort"), sessionID, role: "user", time: { created: Date.now() }, @@ -530,7 +530,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-tools"), + id: MessageID.make("msg_user-tools"), sessionID, role: "user", time: { created: Date.now() }, @@ -609,7 +609,7 @@ describe("session.llm.stream", () => { await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", + $schema: "https://app.kilo.ai/config.json", // kilocode_change enabled_providers: ["openai"], provider: { openai: { @@ -645,7 +645,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-2"), + id: MessageID.make("msg_user-2"), sessionID, role: "user", time: { created: Date.now() }, @@ -760,7 +760,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-data-url"), + id: MessageID.make("msg_user-data-url"), sessionID, role: "user", time: { created: Date.now() }, @@ -851,7 +851,7 @@ describe("session.llm.stream", () => { await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", + $schema: "https://app.kilo.ai/config.json", // kilocode_change enabled_providers: [providerID], provider: { [providerID]: { @@ -881,7 +881,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-3"), + id: MessageID.make("msg_user-3"), sessionID, role: "user", time: { created: Date.now() }, @@ -996,7 +996,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info const user = { - id: MessageID.make("user-anthropic-tools"), + id: MessageID.make("msg_user-anthropic-tools"), sessionID, role: "user", time: { created: Date.now() }, @@ -1210,7 +1210,7 @@ describe("session.llm.stream", () => { await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", + $schema: "https://app.kilo.ai/config.json", // kilocode_change enabled_providers: [providerID], provider: { [providerID]: { @@ -1240,7 +1240,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-4"), + id: MessageID.make("msg_user-4"), sessionID, role: "user", time: { created: Date.now() }, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 08629f5b1b4a..f742b7afc8ef 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -102,9 +102,9 @@ function assistantInfo( function basePart(messageID: string, id: string) { return { - id: PartID.make(id), + id: PartID.make(id.startsWith("prt") ? id : `prt_${id}`), sessionID, - messageID: MessageID.make(messageID), + messageID: MessageID.make(messageID.startsWith("msg") ? messageID : `msg_${messageID}`), } } diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 308f7c859b72..d9406640bb25 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -6,6 +6,7 @@ import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "@/config/config" +import { Image } from "@/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider } from "@/provider/provider" @@ -24,6 +25,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { raw, reply, TestLLMServer } from "../lib/llm-server" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) @@ -166,10 +168,11 @@ const deps = Layer.mergeAll( LLM.defaultLayer, Provider.defaultLayer, status, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(infra)) const env = Layer.mergeAll( TestLLMServer.layer, - SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)), + SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provideMerge(deps)), ) const it = testEffect(env) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 7ebee7a0c5e1..7d53395556b7 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,10 +1,12 @@ import { NodeFileSystem } from "@effect/platform-node" import { FetchHttpClient } from "effect/unstable/http" -import { afterEach, expect, mock, spyOn } from "bun:test" // kilocode_change - spy on review telemetry -import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change - assert review command telemetry +// kilocode_change start +import { afterEach, expect, mock, spyOn } from "bun:test" +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" @@ -17,6 +19,7 @@ import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" import { Git } from "../../src/git" +import { Image } from "../../src/image/image" import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" @@ -47,9 +50,11 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import * as Database from "../../src/storage/db" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" +import { Reference } from "../../src/reference/reference" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) const summary = Layer.succeed( @@ -186,6 +191,7 @@ function makeHttp() { mcp, AppFileSystem.defaultLayer, status, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) @@ -194,6 +200,7 @@ function makeHttp() { Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), @@ -201,12 +208,17 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provideMerge(deps), + ) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(summary), Layer.provideMerge(run), Layer.provideMerge(compact), @@ -442,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* () { @@ -585,8 +704,8 @@ it.live("loop continues when finish is tool-calls", () => ), ) -// kilocode_change - skipped: tracked in #9958 it.live.skip("glob tool keeps instance context during prompt runs", () => + // kilocode_change provideTmpdirServer( ({ dir, llm }) => Effect.gen(function* () { @@ -791,7 +910,7 @@ it.live( }), { git: true, config: providerCfg }, ), - 10_000, // kilocode_change + 10_000, ) // kilocode_change start - child task failures stay tool errors so the parent can recover @@ -903,7 +1022,7 @@ it.live( ) unix( - // kilocode_change - skip flaky cancel test on Windows CI + // kilocode_change "cancel records MessageAbortedError on interrupted process", () => provideTmpdirServer( @@ -1143,7 +1262,7 @@ it.live( const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) yield* llm.wait(1) const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.sleep(50) // kilocode_change - let b attach to a's done deferred before gate resolves + yield* Effect.sleep(50) gate.resolve() const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) @@ -1411,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) => @@ -1900,7 +2051,7 @@ it.live( ), ]), ) - }), + }).pipe(Effect.scoped), // kilocode_change - scope test finalizers explicitly { git: true, config: cfg }, ), 30_000, @@ -1943,7 +2094,7 @@ it.live( ), ]), ) - }), + }).pipe(Effect.scoped), // kilocode_change - scope test finalizers explicitly { git: true, config: cfg }, ), 30_000, diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index e4b6c790935c..191c507f0818 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -15,20 +15,20 @@ import { WorkspaceID } from "../../src/control-plane/schema" // schema we assert: // 1. The Effect decoder (`Schema.decodeUnknownSync`) accepts valid input. // 2. The derived Zod (`X.zod.parse`) accepts the same input and returns the -// same shape. -// 3. Clearly-invalid input is rejected by both paths. +// same shape for schemas that still expose Zod statics. +// 3. Clearly-invalid input is rejected by both paths where both exist. // // The point is to lock down the Schema <-> Zod bridge so a future edit to // any input schema can't silently drop or widen a field on one side. // Representative valid IDs — the branded schemas require the right prefix // (see src/id/id.ts). -const sessionID = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K") -const sessionIDChild = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L") -const messageID = MessageID.zod.parse("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M") -const partID = PartID.zod.parse("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N") +const sessionID = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K") +const sessionIDChild = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L") +const messageID = Schema.decodeUnknownSync(MessageID)("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M") +const partID = Schema.decodeUnknownSync(PartID)("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N") const projectID = ProjectID.zod.parse("proj-alpha") -const workspaceID = WorkspaceID.zod.parse("wrk-primary") +const workspaceID = Schema.decodeUnknownSync(WorkspaceID)("wrk-primary") function decodeUnknown(schema: S) { const decode = Schema.decodeUnknownSync(schema as any) @@ -65,8 +65,7 @@ describe("Session.Info", () => { additions: 10, deletions: 5, files: 2, - // kilocode_change - summary.diffs uses SummaryFileDiff (patch omitted) in kilo - diffs: [{ additions: 1, deletions: 0, file: "a.ts" }], + diffs: [{ additions: 1, deletions: 0, file: "a.ts" }], // kilocode_change }, share: { url: "https://share.example.com/s/1" }, title: "Full session", @@ -260,9 +259,9 @@ describe("SessionStatus.Info", () => { reason: "free_tier_limit", provider: "opencode", title: "Free limit reached", - message: "Subscribe to OpenCode Go.", + message: "Subscribe to OpenCode Go.", // kilocode_change label: "subscribe", - link: "https://opencode.ai/go", + link: "https://opencode.ai/go", // kilocode_change }, next: 500, } diff --git a/packages/opencode/test/session/shell-v2.test.ts b/packages/opencode/test/session/shell-v2.test.ts new file mode 100644 index 000000000000..294b2667b041 --- /dev/null +++ b/packages/opencode/test/session/shell-v2.test.ts @@ -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), + }, + }) + }) +}) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 36aa502cc97c..774b90bd8da7 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -41,6 +41,7 @@ import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" import { Question } from "../../src/question" +import { Image } from "../../src/image/image" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Todo } from "../../src/session/todo" @@ -56,6 +57,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" +import { Reference } from "../../src/reference/reference" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) @@ -122,6 +125,7 @@ function makeHttp() { mcp, AppFileSystem.defaultLayer, status, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) @@ -130,6 +134,7 @@ function makeHttp() { Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), @@ -137,13 +142,18 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(SessionSummary.defaultLayer), + Layer.provide(Image.defaultLayer), + Layer.provideMerge(deps), + ) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionSummary.defaultLayer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(run), Layer.provideMerge(compact), diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index c14f225fa72d..9d1d63747982 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -27,7 +27,7 @@ const runtime = ManagedRuntime.make( const baseCtx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "code", // kilocode_change abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 23ae0e9090f5..a629ff07d11a 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -17,7 +17,7 @@ import { SessionID, MessageID } from "../../src/session/schema" const ctx = { sessionID: SessionID.make("ses_test-edit-session"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 4ee7e80c046f..519042275afe 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -12,7 +12,7 @@ import { SessionID, MessageID } from "../../src/session/schema" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "code", // kilocode_change abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index a1f81742c22a..b54dda91cec9 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -1,7 +1,9 @@ import { describe, expect } from "bun:test" import path from "path" +// kilocode_change start import fs from "fs/promises" import os from "os" +// kilocode_change end import { Cause, Effect, Exit, Layer } from "effect" import { GlobTool } from "../../src/tool/glob" import { SessionID, MessageID } from "../../src/session/schema" @@ -12,6 +14,7 @@ import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const it = testEffect( Layer.mergeAll( @@ -20,12 +23,13 @@ const it = testEffect( Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, + Reference.defaultLayer, ), ) const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 923bb07813db..747639abba1c 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -10,6 +10,7 @@ import { Agent } from "../../src/agent/agent" import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const it = testEffect( Layer.mergeAll( @@ -18,12 +19,13 @@ const it = testEffect( Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, + Reference.defaultLayer, ), ) const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "code", // kilocode_change abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 27623375c22c..875af8e010f6 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -20,7 +20,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 3f2cba89419a..da215db7705b 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -10,7 +10,7 @@ import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test-session"), - messageID: MessageID.make("test-message"), + messageID: MessageID.make("msg_test-message"), callID: "test-call", agent: "test-agent", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 68fdb6bd63ff..dfd2a1df0a26 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -4,6 +4,8 @@ import path from "path" import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" import { LSP } from "@/lsp/lsp" import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" @@ -15,6 +17,7 @@ import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -24,7 +27,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "code", // kilocode_change abort: AbortSignal.any([]), @@ -40,6 +43,7 @@ const it = testEffect( CrossSpawnSpawner.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, + Reference.defaultLayer, Truncate.defaultLayer, ), ) @@ -81,6 +85,49 @@ const fail = Effect.fn("ReadToolTest.fail")(function* ( const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) const glob = (p: string) => process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") +const experimentalScout = (self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Flag.KILO_EXPERIMENTAL_SCOUT + Flag.KILO_EXPERIMENTAL_SCOUT = true + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + Flag.KILO_EXPERIMENTAL_SCOUT = previous + }), + ) +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.KILO_REPO_CLONE_GITHUB_BASE_URL + process.env.KILO_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.KILO_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.KILO_REPO_CLONE_GITHUB_BASE_URL + }), + ) +const git = Effect.fn("ReadToolTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + return stdout.trim() + }) +}) const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) { const fs = yield* AppFileSystem.Service yield* fs.writeWithDirs(p, content) @@ -212,6 +259,46 @@ describe("tool.read external_directory permission", () => { expect(ext).toBeUndefined() }), ) + + it.live("does not ask for external_directory permission when reading configured references", () => + experimentalScout( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-read-reference") + const remoteRepo = path.join(remoteDir, "repo.git") + yield* put(path.join(source, "notes.md"), "reference notes") + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add notes"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const dir = yield* tmpdirScoped({ + git: true, + config: { + reference: { + docs: "opencode-read-reference/repo", + }, + }, + }) + + const { items, next } = asks() + const result = yield* githubBase( + `file://${remoteRoot}/`, + exec(dir, { filePath: path.join(cache, "notes.md") }, next), + ) + const ext = items.find((item) => item.permission === "external_directory") + + expect(result.output).toContain("reference notes") + expect(ext).toBeUndefined() + }), + ), + ) }) describe("tool.read env file permissions", () => { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 012cc0958c9a..78326c737dde 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -27,6 +27,7 @@ import { Ripgrep } from "@/file/ripgrep" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" import { SessionStatus } from "@/session/status" // kilocode_change +import { Reference } from "@/reference/reference" const node = CrossSpawnSpawner.defaultLayer const originalExperimentalScout = Flag.KILO_EXPERIMENTAL_SCOUT @@ -34,7 +35,8 @@ const configLayer = TestConfig.layer({ directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), }) -const registryLayer = ToolRegistry.layer.pipe( +const registryBase = ToolRegistry.layer.pipe( + // kilocode_change Layer.provide(configLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(Question.defaultLayer), @@ -44,6 +46,7 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), @@ -53,10 +56,12 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(node), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Truncate.defaultLayer), - Layer.provide(Command.defaultLayer), // kilocode_change - Layer.provide(SessionStatus.defaultLayer), // kilocode_change ) +// kilocode_change start +const registryLayer = registryBase.pipe(Layer.provide(Command.defaultLayer), Layer.provide(SessionStatus.defaultLayer)) +// kilocode_change end + const it = testEffect(Layer.mergeAll(registryLayer, node)) afterEach(async () => { @@ -88,9 +93,7 @@ describe("tool.registry", () => { } }), ) - // kilocode_change end - // kilocode_change start it.live("suggest is registered for cli and vscode only", () => Effect.gen(function* () { const original = process.env["KILO_CLIENT"] diff --git a/packages/opencode/test/tool/repo_clone.test.ts b/packages/opencode/test/tool/repo_clone.test.ts index 12f196b1c522..5e16e477355a 100644 --- a/packages/opencode/test/tool/repo_clone.test.ts +++ b/packages/opencode/test/tool/repo_clone.test.ts @@ -19,7 +19,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "scout", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts index b4214b7af4f2..556fa05d1fbe 100644 --- a/packages/opencode/test/tool/repo_overview.test.ts +++ b/packages/opencode/test/tool/repo_overview.test.ts @@ -18,7 +18,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "scout", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 3e9385c8bf9c..2fb383f8a2cc 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -36,7 +36,7 @@ const initShell = initBash const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "code", // kilocode_change abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 90d96cdcf558..7518526fdeab 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -14,7 +14,7 @@ import { testEffect } from "../lib/effect" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 20a48f445c5f..c6b46dc7c7c8 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -13,7 +13,7 @@ const projectRoot = path.join(import.meta.dir, "../..") const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make("message"), + messageID: MessageID.make("msg_message"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 8bba52a4b2c8..f6ac57a8ce19 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -18,7 +18,7 @@ import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test-write-session"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/plugin-atomic-chat/package.json b/packages/plugin-atomic-chat/package.json index e795d87cee74..560e37aec61a 100644 --- a/packages/plugin-atomic-chat/package.json +++ b/packages/plugin-atomic-chat/package.json @@ -30,5 +30,6 @@ "type": "git", "url": "https://github.com/Kilo-Org/kilocode", "directory": "packages/plugin-atomic-chat" - } + }, + "peerDependencies": {} } diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index ef993023bbb8..420146ae36b9 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -752,11 +752,11 @@ export type Project = { } export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false + name: "BadRequest" + data: { + message: string + kind?: "Params" | "Headers" | "Query" | "Body" | "Payload" + } } export type NotFoundError = { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 175daa802a59..ba245d1cbf28 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,16 +5,10 @@ export type ClientOptions = { } export type Event = + | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed | EventGlobalConfigUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect - | EventKilocodeAgentManagerStart - | EventIndexingStatus - | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated | EventQuestionAsked @@ -22,6 +16,10 @@ export type Event = | EventQuestionRejected | EventLspClientDiagnostics | EventLspUpdated + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventSessionNetworkAsked @@ -48,6 +46,7 @@ export type Event = | EventSessionCompacted | EventCommandExecuted | EventProjectUpdated + | EventKilocodeAgentManagerStart | EventVcsBranchUpdated | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady @@ -92,6 +91,7 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventIndexingStatus export type OAuth = { type: "oauth" @@ -118,71 +118,6 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - -export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" - -export type IndexingStatus = { - state: IndexingStatusState - message: string - processedFiles: number - totalFiles: number - percent: number -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -245,6 +180,61 @@ export type QuestionRejected = { requestID: string } +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type SessionNetworkWait = { id: string sessionID: string @@ -876,21 +866,25 @@ export type Prompt = { agents?: Array } +export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" + +export type IndexingStatus = { + state: IndexingStatusState + message: string + processedFiles: number + totalFiles: number + percent: number +} + export type GlobalEvent = { directory: string project?: string workspace?: string payload: + | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed | EventGlobalConfigUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventKilocodeAgentManagerStart - | EventIndexingStatus - | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated | EventQuestionAsked @@ -898,6 +892,10 @@ export type GlobalEvent = { | EventQuestionRejected | EventLspClientDiagnostics | EventLspUpdated + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventSessionNetworkAsked @@ -924,6 +922,7 @@ export type GlobalEvent = { | EventSessionCompacted | EventCommandExecuted | EventProjectUpdated + | EventKilocodeAgentManagerStart | EventVcsBranchUpdated | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady @@ -968,6 +967,7 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventIndexingStatus | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -1315,6 +1315,17 @@ export type McpRemoteConfig = { */ export type LayoutConfig = "auto" | "stretch" +export type ImageAttachmentConfig = { + auto_resize?: boolean + max_width?: number + max_height?: number + max_base64_bytes?: number +} + +export type AttachmentConfig = { + image?: ImageAttachmentConfig +} + export type Config = { $schema?: string shell?: string @@ -1438,6 +1449,7 @@ export type Config = { tools?: { [key: string]: boolean } + attachment?: AttachmentConfig enterprise?: { url?: string } @@ -2705,6 +2717,14 @@ export type SyncEventSessionNextCompactionEnded = { } } +export type EventServerInstanceDisposed = { + id: string + type: "server.instance.disposed" + properties: { + directory: string + } +} + export type EventServerConnected = { id: string type: "server.connected" @@ -2729,38 +2749,6 @@ export type EventGlobalConfigUpdated = { } } -export type EventKilocodeAgentManagerStart = { - id: string - type: "kilocode.agent_manager.start" - properties: { - requestID: string - sessionID: string - mode: "worktree" | "local" - versions?: boolean - tasks: Array<{ - prompt?: string - name?: string - branchName?: string - }> - } -} - -export type EventIndexingStatus = { - id: string - type: "indexing.status" - properties: { - status: IndexingStatus - } -} - -export type EventServerInstanceDisposed = { - id: string - type: "server.instance.disposed" - properties: { - directory: string - } -} - export type EventFileEdited = { id: string type: "file.edited" @@ -3054,6 +3042,22 @@ export type EventProjectUpdated = { properties: Project } +export type EventKilocodeAgentManagerStart = { + id: string + type: "kilocode.agent_manager.start" + properties: { + requestID: string + sessionID: string + mode: "worktree" | "local" + versions?: boolean + tasks: Array<{ + prompt?: string + name?: string + branchName?: string + }> + } +} + export type EventVcsBranchUpdated = { id: string type: "vcs.branch.updated" @@ -3580,6 +3584,14 @@ export type EventSessionNextCompactionEnded = { } } +export type EventIndexingStatus = { + id: string + type: "indexing.status" + properties: { + status: IndexingStatus + } +} + export type SessionInfo = { id: string parentID?: string @@ -3816,11 +3828,11 @@ export type EventTuiToastShow1 = { } export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false + name: "BadRequest" + data: { + message: string + kind?: "Params" | "Headers" | "Query" | "Body" | "Payload" + } } export type AuthRemoveData = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 09af2a0056e2..c383071c749e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3795,7 +3795,8 @@ "sessionID": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^ses" }, { "type": "null" @@ -4959,7 +4960,8 @@ "type": "object", "properties": { "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "title": { "type": "string" @@ -4990,7 +4992,8 @@ "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" } }, "additionalProperties": false @@ -5496,7 +5499,7 @@ "in": "query", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": false } @@ -5717,7 +5720,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "model": { "type": "object", @@ -6064,7 +6068,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "additionalProperties": false @@ -6233,7 +6238,8 @@ "type": "string" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["modelID", "providerID", "messageID"], @@ -6555,7 +6561,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "model": { "type": "object", @@ -6737,7 +6744,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "agent": { "type": "string" @@ -6764,7 +6772,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -6888,7 +6897,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "agent": { "type": "string" @@ -6996,10 +7006,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" } }, "required": ["messageID"], @@ -7628,7 +7640,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -7658,7 +7671,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -8821,6 +8835,7 @@ "properties": { "sessionID": { "type": "string", + "pattern": "^ses", "description": "Session ID to navigate to" } }, @@ -9103,7 +9118,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "type": { "type": "string" @@ -9211,7 +9227,8 @@ "type": "object", "properties": { "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "status": { "type": "string", @@ -9355,7 +9372,8 @@ "id": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^wrk" }, { "type": "null" @@ -9363,7 +9381,8 @@ ] }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "copyChanges": { "type": "boolean" @@ -14562,7 +14581,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "callID": { "type": "string" @@ -14579,7 +14599,8 @@ "pattern": "^que" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "questions": { "type": "array", @@ -14608,7 +14629,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { "type": "string", @@ -14628,7 +14650,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { "type": "string", @@ -14761,6 +14784,7 @@ "properties": { "sessionID": { "type": "string", + "pattern": "^ses", "description": "Session ID to navigate to" } }, @@ -14779,7 +14803,8 @@ "pattern": "^que" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "message": { "type": "string" @@ -14812,7 +14837,8 @@ "pattern": "^per" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "permission": { "type": "string" @@ -14836,7 +14862,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "callID": { "type": "string" @@ -14856,7 +14883,8 @@ "type": "string" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "pid": { "type": "integer", @@ -15228,10 +15256,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^sug" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -15353,7 +15383,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^pty" }, "title": { "type": "string" @@ -15381,7 +15412,8 @@ "sessionID": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^ses" }, { "type": "null" @@ -15438,10 +15470,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "role": { "type": "string", @@ -15540,10 +15574,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "role": { "type": "string", @@ -15590,7 +15626,8 @@ ] }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "modelID": { "type": "string" @@ -15693,13 +15730,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -15740,13 +15780,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -15785,13 +15828,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -15956,13 +16002,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -16135,13 +16184,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -16167,13 +16219,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -16190,13 +16245,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -16251,13 +16309,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -16274,13 +16335,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -16303,13 +16367,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -16344,13 +16411,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -16382,13 +16452,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -16401,7 +16474,8 @@ "type": "boolean" }, "tail_start_id": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["id", "sessionID", "messageID", "type", "auto"], @@ -16497,7 +16571,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "slug": { "type": "string" @@ -16506,7 +16581,8 @@ "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "directory": { "type": "string" @@ -16515,7 +16591,8 @@ "type": "string" }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "summary": { "type": "object", @@ -16603,10 +16680,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "snapshot": { "type": "string" @@ -17833,6 +17912,36 @@ "enum": ["auto", "stretch"], "description": "@deprecated Always uses stretch layout." }, + "ImageAttachmentConfig": { + "type": "object", + "properties": { + "auto_resize": { + "type": "boolean" + }, + "max_width": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "max_height": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "max_base64_bytes": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false + }, + "AttachmentConfig": { + "type": "object", + "properties": { + "image": { + "$ref": "#/components/schemas/ImageAttachmentConfig" + } + }, + "additionalProperties": false + }, "Config": { "type": "object", "properties": { @@ -18198,6 +18307,9 @@ "type": "boolean" } }, + "attachment": { + "$ref": "#/components/schemas/AttachmentConfig" + }, "enterprise": { "type": "object", "properties": { @@ -18762,7 +18874,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "slug": { "type": "string" @@ -18771,7 +18884,8 @@ "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "directory": { "type": "string" @@ -18780,7 +18894,8 @@ "type": "string" }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "summary": { "type": "object", @@ -18868,10 +18983,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "snapshot": { "type": "string" @@ -19566,7 +19683,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -19607,7 +19725,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -19633,7 +19752,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -19668,7 +19788,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -19864,6 +19985,7 @@ "properties": { "sessionID": { "type": "string", + "pattern": "^ses", "description": "Session ID to navigate to" } }, @@ -19878,7 +20000,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "type": { "type": "string" @@ -19972,7 +20095,8 @@ "type": "string" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "output": { "type": "string" @@ -20539,7 +20663,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Message" @@ -20577,10 +20702,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["sessionID", "messageID"], @@ -20615,7 +20742,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "part": { "$ref": "#/components/schemas/Part" @@ -20657,13 +20785,16 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" } }, "required": ["sessionID", "messageID", "partID"], @@ -20698,7 +20829,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -20736,7 +20868,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "type": "object", @@ -20744,7 +20877,8 @@ "id": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^ses" }, { "type": "null" @@ -20774,7 +20908,8 @@ "workspaceID": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^wrk" }, { "type": "null" @@ -20804,7 +20939,8 @@ "parentID": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^ses" }, { "type": "null" @@ -20974,10 +21110,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "snapshot": { "type": "string" @@ -21030,7 +21168,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -21071,7 +21210,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -21112,7 +21252,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "model": { "type": "object", @@ -21166,7 +21307,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "prompt": { "$ref": "#/components/schemas/Prompt" @@ -21207,7 +21349,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -21248,7 +21391,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -21292,7 +21436,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -21336,7 +21481,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -21396,7 +21542,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "finish": { "type": "string" @@ -21472,7 +21619,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -21513,7 +21661,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["timestamp", "sessionID"], @@ -21551,7 +21700,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "delta": { "type": "string" @@ -21592,7 +21742,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -21633,7 +21784,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -21674,7 +21826,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -21718,7 +21871,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -21762,7 +21916,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -21806,7 +21961,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -21850,7 +22006,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -21894,7 +22051,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -21954,7 +22112,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -22011,7 +22170,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -22081,7 +22241,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -22138,7 +22299,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "attempt": { "type": "number" @@ -22182,7 +22344,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reason": { "type": "string", @@ -22224,7 +22387,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -22265,7 +22429,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -22589,7 +22754,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { "type": "string", @@ -22617,7 +22783,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { "type": "string", @@ -22645,7 +22812,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { "type": "string", @@ -22676,13 +22844,16 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "field": { "type": "string" @@ -22729,7 +22900,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { "type": "string", @@ -22785,7 +22957,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "processID": { "type": "string" @@ -22812,7 +22985,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -22836,7 +23010,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reason": { "type": "string", @@ -22864,7 +23039,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "diff": { "type": "array", @@ -22894,7 +23070,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "error": { "anyOf": [ @@ -22990,7 +23167,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "todos": { "type": "array", @@ -23020,7 +23198,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "status": { "$ref": "#/components/schemas/SessionStatus" @@ -23047,7 +23226,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -23088,10 +23268,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { - "type": "string" + "type": "string", + "pattern": "^sug" }, "index": { "type": "integer", @@ -23137,10 +23319,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { - "type": "string" + "type": "string", + "pattern": "^sug" } }, "required": ["sessionID", "requestID"], @@ -23164,7 +23348,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -23191,13 +23376,15 @@ "type": "string" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "arguments": { "type": "string" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["name", "sessionID", "arguments", "messageID"], @@ -23241,7 +23428,8 @@ "type": "string" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "mode": { "type": "string", @@ -23390,7 +23578,8 @@ "type": "object", "properties": { "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "status": { "type": "string", @@ -23517,7 +23706,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^pty" }, "exitCode": { "type": "integer", @@ -23545,7 +23735,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^pty" } }, "required": ["id"], @@ -23569,7 +23760,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Message" @@ -23596,10 +23788,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["sessionID", "messageID"], @@ -23623,7 +23817,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "part": { "$ref": "#/components/schemas/Part" @@ -23654,13 +23849,16 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" } }, "required": ["sessionID", "messageID", "partID"], @@ -23684,7 +23882,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -23711,7 +23910,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -23738,7 +23938,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -23768,7 +23969,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -23798,7 +24000,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "model": { "type": "object", @@ -23892,7 +24095,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "prompt": { "$ref": "#/components/schemas/Prompt" @@ -23922,7 +24126,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -23952,7 +24157,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -23985,7 +24191,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -24018,7 +24225,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -24067,7 +24275,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "finish": { "type": "string" @@ -24146,7 +24355,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -24176,7 +24386,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["timestamp", "sessionID"], @@ -24203,7 +24414,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "delta": { "type": "string" @@ -24233,7 +24445,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -24263,7 +24476,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -24293,7 +24507,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -24326,7 +24541,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -24359,7 +24575,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -24392,7 +24609,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -24425,7 +24643,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -24458,7 +24677,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -24541,7 +24761,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -24587,7 +24808,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -24646,7 +24868,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -24723,7 +24946,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "attempt": { "type": "number" @@ -24756,7 +24980,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reason": { "type": "string", @@ -24787,7 +25012,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -24817,7 +25043,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -24861,16 +25088,19 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "projectID": { "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "path": { "type": "string" @@ -25056,7 +25286,8 @@ "additionalProperties": false }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -25531,19 +25762,24 @@ }, "BadRequestError": { "type": "object", - "required": ["data", "errors", "success"], + "required": ["name", "data"], "properties": { - "data": {}, - "errors": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } + "name": { + "type": "string", + "enum": ["BadRequest"] }, - "success": { - "type": "boolean", - "enum": [false] + "data": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["Params", "Headers", "Query", "Body", "Payload"] + } + } } } } diff --git a/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch new file mode 100644 index 000000000000..0a34ba7ed75e --- /dev/null +++ b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch @@ -0,0 +1,14 @@ +diff --git a/photon_rs.js b/photon_rs.js +index 8f4144d..b83e9a9 100644 +--- a/photon_rs.js ++++ b/photon_rs.js +@@ -4509,7 +4509,8 @@ module.exports.__wbindgen_init_externref_table = function() { + ; + }; + +-const 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);