fix: address upstream merge regressions

This commit is contained in:
Johnny Eric Amancio
2026-08-04 18:14:39 +02:00
parent 054ee59491
commit 10a3eec7bd
27 changed files with 465 additions and 80 deletions
@@ -17,6 +17,7 @@ Changes from opencode v1.17.9 to v1.17.13 upstream:
- Core Bugfixes: MCP tool results prefer content over structured output, and denied resource template tools stay hidden.
- Core Bugfixes: Stale GitHub Copilot Responses item IDs are no longer replayed, and OpenAI reasoning variants are forced where required.
- Core Bugfixes: Adaptive thinking is enabled for Claude Sonnet 5, and expired promos were removed from the zen catalog.
- Core Bugfixes: Preserve released prompt history during database replay and keep native event streams connected for all supported Kilo events.
- Core Bugfixes: Remote skills refresh atomically with version pinning, and skill base directories are emitted as filesystem paths.
- CLI Improvements: `kilo run --mini` provides a compact interactive mode, and ports increment from the default when busy.
- CLI Improvements: Use `--auto` to start the TUI in a run-scoped auto-approve mode, and leave the mode mid-session from the command palette.
+1
View File
@@ -12,6 +12,7 @@
"generate": "bun run script/build.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/generated src/generated-effect",
"test": "bun test --timeout 5000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 5000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
+51 -51
View File
@@ -250,7 +250,7 @@ export function make(options: ClientOptions) {
health: {
get: (requestOptions?: RequestOptions) =>
request<HealthGetOutput>(
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
},
@@ -262,7 +262,7 @@ export function make(options: ClientOptions) {
path: `/api/location`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -276,7 +276,7 @@ export function make(options: ClientOptions) {
path: `/api/agent`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -316,7 +316,7 @@ export function make(options: ClientOptions) {
location: input?.["location"],
},
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -327,7 +327,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/active`,
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -338,7 +338,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -350,7 +350,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
body: { agent: input["agent"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -362,7 +362,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
body: { model: input["model"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -374,7 +374,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
successStatus: 200,
declaredStatuses: [409, 404, 400, 401],
declaredStatuses: [400, 401, 404, 409],
empty: false,
},
requestOptions,
@@ -385,7 +385,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
successStatus: 204,
declaredStatuses: [404, 503, 400, 401],
declaredStatuses: [400, 401, 404, 503],
empty: true,
},
requestOptions,
@@ -396,7 +396,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`,
successStatus: 204,
declaredStatuses: [404, 503, 400, 401],
declaredStatuses: [400, 401, 404, 503],
empty: true,
},
requestOptions,
@@ -408,7 +408,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
body: { messageID: input["messageID"], files: input["files"] },
successStatus: 200,
declaredStatuses: [404, 500, 400, 401],
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
@@ -419,7 +419,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
successStatus: 204,
declaredStatuses: [404, 500, 400, 401],
declaredStatuses: [400, 401, 404, 500],
empty: true,
},
requestOptions,
@@ -430,7 +430,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -441,7 +441,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
successStatus: 200,
declaredStatuses: [404, 500, 400, 401],
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
@@ -453,7 +453,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
query: { limit: input["limit"], after: input["after"] },
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -465,7 +465,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
query: { after: input["after"] },
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -476,7 +476,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -487,7 +487,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -501,7 +501,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
successStatus: 200,
declaredStatuses: [400, 404, 500, 401],
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
@@ -515,7 +515,7 @@ export function make(options: ClientOptions) {
path: `/api/model`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -529,7 +529,7 @@ export function make(options: ClientOptions) {
path: `/api/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -541,7 +541,7 @@ export function make(options: ClientOptions) {
path: `/api/provider/${encodeURIComponent(input.providerID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
@@ -555,7 +555,7 @@ export function make(options: ClientOptions) {
path: `/api/integration`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -567,7 +567,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -605,7 +605,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -630,7 +630,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -645,7 +645,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { label: input["label"] },
successStatus: 204,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -657,7 +657,7 @@ export function make(options: ClientOptions) {
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -671,7 +671,7 @@ export function make(options: ClientOptions) {
path: `/api/permission/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -683,7 +683,7 @@ export function make(options: ClientOptions) {
path: `/api/permission/saved`,
query: { projectID: input?.["projectID"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -694,7 +694,7 @@ export function make(options: ClientOptions) {
method: "DELETE",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
successStatus: 204,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -714,7 +714,7 @@ export function make(options: ClientOptions) {
agent: input["agent"],
},
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -725,7 +725,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -736,7 +736,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -748,7 +748,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
body: { reply: input["reply"], message: input["message"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -762,7 +762,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/list`,
query: { location: input?.["location"], path: input?.["path"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -774,7 +774,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/find`,
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -788,7 +788,7 @@ export function make(options: ClientOptions) {
path: `/api/command`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -802,7 +802,7 @@ export function make(options: ClientOptions) {
path: `/api/skill`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -811,7 +811,7 @@ export function make(options: ClientOptions) {
events: {
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> =>
sse<EventsSubscribeOutput>(
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
},
@@ -823,7 +823,7 @@ export function make(options: ClientOptions) {
path: `/api/pty`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -842,7 +842,7 @@ export function make(options: ClientOptions) {
env: input?.["env"],
},
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -854,7 +854,7 @@ export function make(options: ClientOptions) {
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -867,7 +867,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { title: input["title"], size: input["size"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -879,7 +879,7 @@ export function make(options: ClientOptions) {
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -893,7 +893,7 @@ export function make(options: ClientOptions) {
path: `/api/question/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -904,7 +904,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -916,7 +916,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
body: { answers: input["answers"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -927,7 +927,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -941,7 +941,7 @@ export function make(options: ClientOptions) {
path: `/api/reference`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
+58 -4
View File
@@ -8,10 +8,6 @@ export type JsonValue =
| ReadonlyArray<JsonValue>
| { readonly [key: string]: JsonValue }
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
export type InvalidRequestError = {
readonly _tag: "InvalidRequestError"
readonly message: string
@@ -21,6 +17,10 @@ export type InvalidRequestError = {
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError"
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -1133,6 +1133,33 @@ export type SessionsHistoryOutput = {
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.prompt.promoted"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly prompt: {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
}
readonly timeCreated: number
}
}
>
readonly hasMore: boolean
}
@@ -1592,6 +1619,33 @@ export type SessionsEventsOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.prompt.promoted"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly prompt: {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
}
readonly timeCreated: number
}
}
export type SessionsInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
+5 -1
View File
@@ -2,8 +2,12 @@ export * as KiloOauthCallbackPage from "./page"
import { OauthCallbackPage, type CallbackPageOptions } from "../../oauth/page"
const KILO_MARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" aria-label="Kilo Code" role="img">
<path fill="currentColor" d="M0 0v100h100V0H0Zm92.592 92.592H7.407V7.407h85.185v85.185ZM61.111 71.91h9.259v7.407H58.73l-5.026-5.027V62.65h7.407v9.26Zm16.667 0H70.37v-9.26h-9.259v-7.407h11.64l5.027 5.026V71.91ZM46.296 61.111H38.89v-7.407h7.407v7.407ZM22.222 53.704h7.408V70.37h16.666v7.408H27.249l-5.027-5.027V53.704Zm55.556-14.815v7.407H53.704V38.89h8.278V29.63h-8.278v-7.408h10.659l5.026 5.027v11.64h8.389ZM29.63 30.556h9.259l7.407 7.407v8.333H38.89v-8.333H29.63v8.333h-7.408V22.222h7.408v8.334Zm16.666 0H38.89v-8.334h7.407v8.334Z" />
</svg>`
function brand(page: string) {
return page.replaceAll("OpenCode", "Kilo")
return page.replace(/<svg class="wordmark"[\s\S]*?<\/svg>/, KILO_MARK).replaceAll("OpenCode", "Kilo")
}
export function success(options?: CallbackPageOptions) {
@@ -0,0 +1,42 @@
import { PromptPromoted } from "@opencode-ai/schema/kilocode/durable-event"
import { Effect } from "effect"
import { Database } from "../../database/database"
import { SessionEvent } from "../../session/event"
import { SessionInput } from "../../session/input"
export const definition = PromptPromoted
export function project<E, R>(
db: Database.Interface["db"],
event: typeof PromptPromoted.Type,
commit: (event: typeof SessionEvent.Prompted.Type) => Effect.Effect<void, E, R>,
) {
return Effect.gen(function* () {
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
const admitted = yield* SessionInput.find(db, event.data.messageID)
if (!admitted) return yield* Effect.die(new SessionInput.LifecycleConflict({ id: event.data.messageID }))
const prompted = SessionEvent.Prompted.make({
id: event.id,
type: SessionEvent.Prompted.type,
durable: event.durable,
location: event.location,
metadata: event.metadata,
data: {
sessionID: event.data.sessionID,
messageID: event.data.messageID,
timestamp: event.data.timeCreated,
prompt: event.data.prompt,
delivery: admitted.delivery,
},
})
yield* SessionInput.projectPrompted(db, {
id: prompted.data.messageID,
sessionID: prompted.data.sessionID,
prompt: prompted.data.prompt,
delivery: prompted.data.delivery,
timeCreated: prompted.data.timestamp,
promotedSeq: event.durable.seq,
})
yield* commit(prompted)
})
}
+5 -5
View File
@@ -37,7 +37,7 @@ import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
import { SessionDurable, type SessionDurableEvent } from "@opencode-ai/schema/durable-event-manifest" // kilocode_change
export const RevertState = Revert.State
export type RevertState = Revert.State
@@ -134,12 +134,12 @@ export interface Interface {
readonly events: (input: {
sessionID: SessionSchema.ID
after?: number
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
}) => Stream.Stream<SessionDurableEvent, NotFoundError> // kilocode_change - released durable event compatibility
readonly history: (input: {
sessionID: SessionSchema.ID
after?: number
limit: number
}) => Effect.Effect<{ events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean }, NotFoundError>
}) => Effect.Effect<{ events: ReadonlyArray<SessionDurableEvent>; hasMore: boolean }, NotFoundError> // kilocode_change
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: {
sessionID: SessionSchema.ID
@@ -193,7 +193,7 @@ const layer = Layer.effect(
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const isDurableSessionEvent = Schema.is(SessionDurable.schema) // kilocode_change - include released storage keys
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage(normalize({ ...row.data, id: row.id, type: row.type })).pipe(
// kilocode_change - normalize released tool content on paginated reads
@@ -356,7 +356,7 @@ const layer = Layer.effect(
result
.get(input.sessionID)
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
).pipe(Stream.filter((event): event is SessionDurableEvent => isDurableSessionEvent(event))), // kilocode_change
history: Effect.fn("V2Session.history")(function* (input) {
yield* result.get(input.sessionID)
return yield* EventV2.readAggregate(db, {
+2
View File
@@ -16,6 +16,7 @@ import { WorkspaceV2 } from "../workspace"
import { SessionContextEpoch } from "./context-epoch"
import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
import * as PromptCompat from "../kilocode/session/prompt-promoted" // kilocode_change - released replay key
type DatabaseService = Database.Interface["db"]
@@ -393,6 +394,7 @@ const layer = Layer.effectDiscard(
})
}),
)
yield* events.project(PromptCompat.definition, (event) => PromptCompat.project(db, event, (next) => run(db, next))) // kilocode_change - replay released two-step promotions
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
@@ -1,5 +1,6 @@
import { expect } from "bun:test"
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -10,10 +11,20 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import * as StoredMessage from "@opencode-ai/core/kilocode/session-message"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
const database = Database.layerFromPath(":memory:")
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([EventV2.node, Database.node]), [[Database.node, database]]),
const it = testEffect(AppNodeBuilder.build(LayerNode.group([EventV2.node, Database.node]), [[Database.node, database]]))
const replay = testEffect(
AppNodeBuilder.build(LayerNode.group([EventV2.node, Database.node, SessionProjector.node]), [
[Database.node, database],
]),
)
it.effect("decodes legacy durable tool content without exposing it to consumers", () =>
@@ -98,6 +109,76 @@ it.effect("writes released durable tool and compaction shapes", () =>
}),
)
replay.effect("reads and replays released prompt promotion events", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const sessionID = SessionV2.ID.make("ses_prompt_promoted_compat")
const messageID = SessionMessage.ID.make("msg_prompt_promoted_compat")
const prompt = Prompt.make({ text: "Promoted from a released session" })
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "compat",
directory: "/project",
title: "compat",
version: "test",
})
.run()
.pipe(Effect.orDie)
yield* events.replayAll(
[
{
id: EventV2.ID.make("evt_prompt_admitted_compat"),
type: "session.next.prompt.admitted.1",
aggregateID: sessionID,
seq: 0,
data: { timestamp: 1, sessionID, messageID, prompt, delivery: "queue" },
},
{
id: EventV2.ID.make("evt_prompt_promoted_compat"),
type: "session.next.prompt.promoted.1",
aggregateID: sessionID,
seq: 1,
data: { timestamp: 2, sessionID, messageID, prompt, timeCreated: 1 },
},
],
{ publish: true },
)
const history = yield* EventV2.readAggregate(db, {
aggregateID: sessionID,
limit: 10,
manifest: SessionDurable,
})
expect(history.events.map((event) => event.type)).toEqual([
"session.next.prompt.admitted",
"session.next.prompt.promoted",
])
expect(yield* events.durable({ aggregateID: sessionID }).pipe(Stream.take(2), Stream.runCollect)).toHaveLength(2)
expect(yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, messageID)).get()).toMatchObject({
delivery: "queue",
promoted_seq: 1,
})
expect(
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, messageID)).get(),
).toMatchObject({
id: messageID,
type: "user",
seq: 1,
})
}),
)
it.effect("stores self-contained compaction projections for released readers", () =>
Effect.sync(() => {
const encoded = StoredMessage.encode({
+1
View File
@@ -8,6 +8,7 @@
},
"scripts": {
"test": "bun test --timeout 5000 --only-failures",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 5000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
+5
View File
@@ -111,6 +111,11 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
const errorSchemas = Array.from(errors).flatMap(([status, schemas]) =>
schemas.map((schema) => ({ status, ...normalizeTransport(schema, "error", endpoint, name)! })),
)
errorSchemas.sort((a, b) => {
const left = SchemaAST.resolveIdentifier(a.schema.ast) ?? ""
const right = SchemaAST.resolveIdentifier(b.schema.ast) ?? ""
return a.status - b.status || (left < right ? -1 : left > right ? 1 : 0)
})
const inputs = [
...inputFields(params?.schema, "params", name),
...inputFields(query?.schema, "query", name),
@@ -25,6 +25,21 @@ function compile<Id extends string, Groups extends HttpApiGroup.Any>(source: Htt
}
describe("HttpApiCodegen.generate", () => {
test("orders endpoint errors deterministically", () => {
class Alpha extends Schema.TaggedErrorClass<Alpha>()("Alpha", {}) {}
class Beta extends Schema.TaggedErrorClass<Beta>()("Beta", {}) {}
const endpoint = (errors: readonly [typeof Alpha, typeof Beta] | readonly [typeof Beta, typeof Alpha]) =>
HttpApiEndpoint.get("get", "/session", {
success: Schema.String,
error: errors.map((error) => error.pipe(HttpApiSchema.status(400))),
})
const alpha = compileContract(api(endpoint([Alpha, Beta])))
const beta = compileContract(api(endpoint([Beta, Alpha])))
expect(emitPromise(alpha)).toEqual(emitPromise(beta))
})
test("compiles one contract for Promise and Effect emitters", () => {
const contract = compileContract(
api(
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { KiloOauthCallbackPage } from "@opencode-ai/core/kilocode/oauth/page"
const root = path.join(__dirname, "..", "..")
@@ -16,13 +17,22 @@ describe("Kilo OAuth branding", () => {
test("core OAuth browser flow uses Kilo branding", async () => {
const src = await Bun.file(path.join(root, "..", "core", "src", "plugin", "provider", "openai.ts")).text()
const page = await Bun.file(path.join(root, "..", "core", "src", "kilocode", "oauth", "page.ts")).text()
const pages = [
KiloOauthCallbackPage.success({ provider: "ChatGPT" }),
KiloOauthCallbackPage.error("Denied", { provider: "ChatGPT" }),
]
expect(src).toContain('originator: "kilo"')
expect(src).toContain('"User-Agent": `kilo/${InstallationVersion}`')
expect(src).toContain("KiloOauthCallbackPage")
expect(page).toContain('.replaceAll("OpenCode", "Kilo")')
expect(src).not.toContain('originator: "opencode"')
for (const page of pages) {
expect(page).toContain("· Kilo</title>")
expect(page).toContain('aria-label="Kilo Code"')
expect(page).toContain('viewBox="0 0 100 100"')
expect(page).not.toContain("OpenCode")
expect(page).not.toContain('viewBox="0 0 234 42"')
}
})
test("MCP OAuth callback page uses Kilo branding", async () => {
@@ -105,6 +105,31 @@ describe("configured references", () => {
expect(updates).toEqual(["reference.updated"])
})
test("sync replaces stale effective references", async () => {
const cache = Layer.mock(RepositoryCache.Service, {
ensure: () => Effect.die("unexpected Git materialization"),
})
const events = Layer.mock(EventV2.Service)({
publish: (definition, data) =>
Effect.succeed({ id: EventV2.ID.make("evt_reference_replace"), type: definition.type, data }),
})
const layer = AppNodeBuilder.build(CoreReference.node, [
[RepositoryCache.node, cache],
[EventV2.node, events],
])
const result = await Effect.runPromise(
Effect.gen(function* () {
yield* Reference.sync({ references: { stale: "./stale" }, directory: "/workspace", worktree: "/workspace" })
yield* Reference.sync({ references: { current: "./current" }, directory: "/workspace", worktree: "/workspace" })
return yield* (yield* CoreReference.Service).list()
}).pipe(Effect.provide(layer), Effect.scoped),
)
expect(result.map((item) => item.name)).toEqual(["current"])
expect(result[0]?.path).toBe(AbsolutePath.make(path.resolve("/workspace", "current")))
})
test("initializes effective references before exposing location services", async () => {
await using tmp = await tmpdir({
config: {
@@ -115,7 +140,10 @@ describe("configured references", () => {
},
},
})
const layer = locations.pipe(Layer.provide(AppNodeBuilder.build(Config.node)), Layer.provide(testInstanceStoreLayer))
const layer = locations.pipe(
Layer.provide(AppNodeBuilder.build(Config.node)),
Layer.provide(testInstanceStoreLayer),
)
const result = await Effect.runPromise(
Effect.gen(function* () {
@@ -0,0 +1,12 @@
import { expect, test } from "bun:test"
import path from "path"
test("Kilo releases do not publish upstream-owned packages", async () => {
const root = path.join(import.meta.dir, "../../../..")
const src = await Bun.file(path.join(root, "script", "publish.ts")).text()
expect(src).not.toContain("packages/ui/script/publish.ts")
expect(src).toContain("packages/opencode/script/publish.ts")
expect(src).toContain("packages/sdk/js/script/publish.ts")
expect(src).toContain("packages/plugin/script/publish.ts")
})
@@ -128,11 +128,23 @@ describe("v2 location HttpApi", () => {
const created = await request("/session", publisher.path, { method: "POST" })
expect(created.status).toBe(200)
// kilocode_change start - the native handler must encode Kilo events omitted from upstream's narrower manifest
const session = (await created.json()) as { id: string }
expect(await readEventType(reader, "session.created")).toMatchObject({
type: "session.created",
location: { directory: publisher.path },
data: { sessionID: expect.any(String) },
data: { sessionID: session.id },
})
const aborted = await request(`/session/${session.id}/abort`, publisher.path, { method: "POST" })
expect(aborted.status).toBe(200)
expect(await readEventType(reader, "session.status")).toMatchObject({
type: "session.status",
location: { directory: publisher.path },
data: { sessionID: session.id, status: { type: "idle" } },
})
// kilocode_change end
await reader.return(undefined)
})
})
+2
View File
@@ -54,3 +54,5 @@ export const EventGroup = event.group
export const OpenCodeEvent = event.schema
export type OpenCodeEvent = typeof OpenCodeEvent.Type
export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded
export const KiloEvent = schema(EventManifest.Definitions) // kilocode_change - encode the full Kilo event bus
+3 -2
View File
@@ -21,6 +21,7 @@ import { Model } from "@opencode-ai/schema/model"
import { Location } from "@opencode-ai/schema/location"
import { Revert } from "@opencode-ai/schema/revert"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" // kilocode_change - released history keys
const SessionsQueryFields = {
workspace: Workspace.ID.pipe(Schema.optional),
@@ -318,7 +319,7 @@ export const makeSessionGroup = <
params: { sessionID: Session.ID },
query: SessionHistoryQuery,
success: Schema.Struct({
data: Schema.Array(SessionEvent.Durable),
data: Schema.Array(SessionDurable.schema), // kilocode_change
hasMore: Schema.Boolean,
}).annotate({ identifier: "SessionHistory" }),
error: SessionNotFoundError,
@@ -339,7 +340,7 @@ export const makeSessionGroup = <
query: {
after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional),
},
success: HttpApiSchema.StreamSse({ data: SessionEvent.Durable }),
success: HttpApiSchema.StreamSse({ data: SessionDurable.schema }), // kilocode_change
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
+13 -3
View File
@@ -1,15 +1,25 @@
export * as DurableEventManifest from "./durable-event-manifest"
import { Event } from "./event"
import { Schema } from "effect" // kilocode_change
import { SessionEvent } from "./session-event"
import { SessionV1 } from "./session-v1"
import { PromptPromoted } from "./kilocode/durable-event" // kilocode_change - released storage key
// kilocode_change start - retain the released prompt promotion event for history and replay
const definitions = Event.inventory(...SessionEvent.DurableDefinitions, PromptPromoted)
const schema = Schema.Union(definitions, { mode: "oneOf" })
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "SessionDurableEvent" })
export type SessionDurableEvent = typeof schema.Type
// kilocode_change end
export const SessionDurable = {
definitions: Event.durable(SessionEvent.DurableDefinitions),
schema: SessionEvent.Durable,
definitions: Event.durable(definitions), // kilocode_change
schema, // kilocode_change
} as const
export const Durable = Event.durable([
...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined),
...SessionEvent.DurableDefinitions,
...definitions, // kilocode_change
])
@@ -0,0 +1,17 @@
import { Event } from "../event"
import { Prompt } from "../prompt"
import { DateTimeUtcFromMillis } from "../schema"
import { SessionID } from "../session-id"
import { SessionMessage } from "../session-message"
export const PromptPromoted = Event.define({
type: "session.next.prompt.promoted",
durable: { aggregate: "sessionID", version: 1 },
schema: {
timestamp: DateTimeUtcFromMillis,
sessionID: SessionID,
messageID: SessionMessage.ID,
prompt: Prompt,
timeCreated: DateTimeUtcFromMillis,
},
})
+1
View File
@@ -9,6 +9,7 @@
},
"scripts": {
"test": "bun test --timeout 5000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 5000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
+22
View File
@@ -4531,6 +4531,7 @@ export type SessionDurableEvent =
| SessionNextRevertStaged
| SessionNextRevertCleared
| SessionNextRevertCommitted
| SessionNextPromptPromoted
export type SessionHistory = {
data: Array<SessionDurableEvent>
@@ -7770,6 +7771,27 @@ export type SessionNextRevertCommitted = {
}
}
export type SessionNextPromptPromoted = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.prompt.promoted"
durable?: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
messageID: string
prompt: Prompt
timeCreated: number
}
}
export type ModelApi =
| {
id: string
+64
View File
@@ -39495,6 +39495,9 @@
},
{
"$ref": "#/components/schemas/SessionNextRevertCommitted"
},
{
"$ref": "#/components/schemas/SessionNextPromptPromoted"
}
]
},
@@ -50022,6 +50025,67 @@
"required": ["id", "type", "data"],
"additionalProperties": false
},
"SessionNextPromptPromoted": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^evt_"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["session.next.prompt.promoted"]
},
"durable": {
"type": "object",
"properties": {
"aggregateID": {
"type": "string"
},
"seq": {
"type": "integer"
},
"version": {
"type": "integer"
}
},
"required": ["aggregateID", "seq", "version"],
"additionalProperties": false
},
"location": {
"$ref": "#/components/schemas/LocationRef"
},
"data": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"messageID": {
"type": "string",
"pattern": "^msg_"
},
"prompt": {
"$ref": "#/components/schemas/Prompt"
},
"timeCreated": {
"type": "number"
}
},
"required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"],
"additionalProperties": false
}
},
"required": ["id", "type", "data"],
"additionalProperties": false
},
"ModelApi": {
"anyOf": [
{
+2 -2
View File
@@ -1,5 +1,5 @@
import { EventV2 } from "@opencode-ai/core/event"
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { KiloEvent } from "@opencode-ai/protocol/groups/event" // kilocode_change - encode the full Kilo event bus
import { Effect, Schema, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -13,7 +13,7 @@ function eventData(data: unknown): Sse.Event {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(data)),
data: JSON.stringify(Schema.encodeUnknownSync(KiloEvent)(data)), // kilocode_change
}
}
+2 -1
View File
@@ -22,7 +22,8 @@
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test src --only-failures"
"test": "bun test src --only-failures",
"test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
+1 -2
View File
@@ -122,8 +122,7 @@ await import(`../packages/sdk/js/script/publish.ts`)
console.log("\n=== plugin ===\n")
await import(`../packages/plugin/script/publish.ts`)
console.log("\n=== ui ===\n")
await $`bun ./packages/ui/script/publish.ts`
// kilocode_change - Kilo does not publish the upstream-owned @opencode-ai/ui package
// kilocode_change start
console.log("\n=== vscode ===\n")
+3 -3
View File
@@ -13,7 +13,7 @@ This document breaks the legacy configuration schema into small review groups. W
Use one v2 config schema for now. Some fields, such as `autoupdate`, are intended for global/user configuration, but there is not yet enough benefit to enforce that with separate global and location schemas. Revisit this if more scope-sensitive fields survive the review.
V2 core discovers config documents named `opencode.json` or `opencode.jsonc` in the global config directory, ancestor project directories, and `.opencode` config directories. The legacy `config.json` filename is not supported in V2.
V2 core discovers config documents named `config.json`, `kilo.json`, `kilo.jsonc`, `opencode.json`, or `opencode.jsonc` in the global Kilo config directory, ancestor project directories, and `.kilo` or legacy `.kilocode` config directories. Kilo deliberately ignores `.opencode` directories.
## Group 1: File Metadata
@@ -107,7 +107,7 @@ Plugin order remains part of the v2 configuration contract because hook registra
}
```
The configured `plugins` list represents package-loaded plugins only. Local plugin code remains discovered from plugin directories such as `.opencode/plugins/`; v2 does not port arbitrary configured local paths or file URLs into this field.
The configured `plugins` list represents package-loaded plugins only. Local plugin code remains discovered from plugin directories such as `.kilo/plugins/` and legacy `.kilocode/plugins/`; v2 does not port arbitrary configured local paths or file URLs into this field.
## Group 5: Filesystem And Tool Runtime
@@ -201,7 +201,7 @@ Provider selection rules belong in `experimental.policies` rather than provider
See [provider-policy.md](./provider-policy.md) for the provider policy semantics and precedence rules.
Policy evaluation will consume authored config documents in reverse order while preserving statement order inside each document. The precedence of `.opencode` policy sources remains open until `.opencode` configuration is reviewed.
Policy evaluation will consume authored config documents in reverse order while preserving statement order inside each document. The precedence of `.kilo` and legacy `.kilocode` policy sources remains open until Kilo configuration is reviewed.
Provider configuration uses the plural `providers` key in v2. This intentionally differs from the legacy singular `provider` key; v2 does not add a compatibility alias while its configuration surface is still being defined.