resolve merge conflicts

This commit is contained in:
Imanol Maiztegui
2026-05-22 09:01:40 +02:00
2185 changed files with 235662 additions and 1963 deletions
@@ -0,0 +1 @@
ALTER TABLE `event_sequence` ADD `owner_id` text;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -83,7 +83,7 @@
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@agentclientprotocol/sdk": "0.21.0",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/anthropic": "3.0.71",
+2 -2
View File
@@ -776,9 +776,9 @@ const scenarios: Scenario[] = [
}))
.status(200),
http
.post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore")
.post("/experimental/workspace/warp", "experimental.workspace.warp")
.at((ctx) => ({
path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }),
path: "/experimental/workspace/warp",
headers: ctx.headers(),
body: {},
}))
+329
View File
@@ -0,0 +1,329 @@
# Typed error migration
Plan for moving `packages/opencode` from temporary defect/`NamedError`
compatibility toward typed Effect service errors and explicit HTTP error
contracts.
## Goal
- Expected service failures live on the Effect error channel.
- Service interfaces expose those failures in their return types.
- Domain errors are authored with Effect Schema so they are reusable by services,
tests, HTTP routes, tools, and OpenAPI generation.
- HTTP status codes and wire compatibility are handled at the HTTP boundary, not
inside service modules.
- `Effect.die`, `throw`, `catchDefect`, and global cause inspection are reserved
for defects, compatibility bridges, or final fallback behavior.
## Current State
- Many migrated services use Effect internally, but expected failures are still a
mix of `NamedError.create(...)`, `namedSchemaError(...)`, `class extends Error`,
`throw`, and `Effect.die(...)`.
- Some services already use `Schema.TaggedErrorClass`, for example `Account`,
`Auth`, `Permission`, `Question`, `Installation`, and parts of
`Workspace`.
- Legacy Hono error handling recognizes `NamedError`, `Session.BusyError`, and a
few name-based cases, then emits the legacy `{ name, data }` JSON body.
- Effect `HttpApi` only knows how to encode errors that are declared on the
endpoint, group, or middleware. Undeclared expected errors become defects and
eventually fall through to generic HTTP handling.
- The temporary HttpApi error middleware catches defect-wrapped legacy errors to
preserve runtime behavior, but it is intentionally a bridge rather than the
final model.
## End State
Service modules own domain failures.
```ts
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
sessionID: SessionID,
message: Schema.String,
}) {}
export type Error = Storage.Error | SessionBusyError
export interface Interface {
readonly get: (id: SessionID) => Effect.Effect<Info, Error>
}
```
HTTP modules own transport mapping.
```ts
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* session
.get(ctx.params.sessionID)
.pipe(
Effect.catchTag("StorageNotFoundError", () => new SessionNotFoundHttpError({ sessionID: ctx.params.sessionID })),
)
})
```
HTTP-visible error schemas carry their own response status through Effect
HttpApi's `httpApiStatus` annotation. Prefer `HttpApiSchema.status(...)`, or the
equivalent declaration annotation, instead of maintaining a parallel status map.
```ts
export class SessionNotFoundHttpError extends Schema.TaggedErrorClass<SessionNotFoundHttpError>()(
"SessionNotFoundHttpError",
{
sessionID: SessionID,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
```
Endpoint definitions still declare which HTTP-visible error schemas can be
emitted. The status annotation is only used if the error is part of the endpoint,
group, or middleware error schema and the handler fails with that error on the
typed error channel.
```ts
HttpApiEndpoint.get("get", SessionPaths.get, {
success: Session.Info,
error: [SessionNotFoundHttpError, SessionBusyHttpError],
})
```
The service error and HTTP error may be the same class when the wire shape is a
deliberate public contract. They should be different classes when the service
error contains internals, low-level causes, retry hints, or anything that should
not be exposed to API clients.
## Rules
- Use `Schema.TaggedErrorClass` for new expected domain errors.
- Include `cause: Schema.optional(Schema.Defect)` only when preserving an
underlying unknown failure is useful for logs or callers.
- Export a domain-level error union from each service module, for example
`export type Error = NotFoundError | BusyError | Storage.Error`.
- Put expected errors in service method signatures, for example
`Effect.Effect<Result, Service.Error, R>`.
- Use `yield* new DomainError(...)` for direct early failures inside
`Effect.gen` / `Effect.fn`.
- Use `Effect.try({ try, catch })`, `Effect.mapError`, or `Effect.catchTag` to
convert external exceptions into domain errors.
- Use `HttpApiSchema.status(...)` or `{ httpApiStatus: code }` on HTTP-visible
error schemas so Effect `HttpApiBuilder` and OpenAPI generation get the status
from the schema itself.
- Do not use `Effect.die(...)` for user, IO, validation, missing-resource, auth,
provider, worktree, or busy-state failures.
- Do not use `catchDefect` to recover expected domain errors. If recovery is
needed, the upstream effect should fail with a typed error instead.
- Do not make service modules import `HttpApiError`, `HttpServerResponse`, HTTP
status codes, or route-specific error schemas.
- Keep raw `HttpRouter` routes free to use `HttpServerRespondable` when that is
the right transport abstraction, but prefer declared `HttpApi` errors for
normal JSON API endpoints.
## HTTP Boundary Shape
Create an HttpApi-local error module, likely
`src/server/routes/instance/httpapi/errors.ts`.
That module should provide:
- Legacy-compatible public schemas for `{ name, data }` error bodies that must
remain SDK-compatible during the Hono migration.
- Small constructors or mapping helpers for common API errors such as not found,
bad request, conflict, and unknown internal errors.
- Route-group-specific adapters only when they encode domain-specific public
data.
- A single place to document which public error shape is legacy-compatible and
which shape is new Effect-native API surface.
Avoid one giant `unknown -> status` mapper. Prefer small, explicit mappers close
to the handler or route group.
```ts
const mapSessionError = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchTag("StorageNotFoundError", (error) => new SessionNotFoundHttpError({ message: error.message })),
Effect.catchTag("SessionBusyError", (error) => new SessionBusyHttpError({ message: error.message })),
)
```
Use built-in `HttpApiError.BadRequest`, `HttpApiError.NotFound`, and related
types only when their generated response body and SDK surface are intentionally
acceptable. Use a custom schema-backed error when clients need the legacy
`{ name, data }` body or a domain-specific error payload.
## Migration Phases
### 1. Stabilize The Bridge
Keep the temporary HttpApi error middleware only as a compatibility bridge while
typed errors are introduced.
- Add tests that prove the bridge catches legacy `NamedError` defects.
- Add tests that prove declared HttpApi errors still use the declared endpoint
contract.
- Stop returning stack traces in unknown HTTP `500` responses; log the full
`Cause.pretty(cause)` server-side instead.
- Add a comment or TODO that names this plan and states the bridge must shrink
as route groups migrate.
### 2. Define The Shared HTTP Error Helpers
Add the `httpapi/errors.ts` module before converting route groups.
- Define a legacy `{ name, data }` body helper for SDK-compatible errors.
- Define `UnknownError` for generic internal failures with a safe public message.
- Define `BadRequestError` and `NotFoundError` equivalents only if the actual
wire body must match the legacy Hono SDK surface.
- Put the HTTP status on the public schema with `HttpApiSchema.status(...)` or
`{ httpApiStatus: code }`; do not keep a separate name-to-status table.
- Keep conversion helpers pure and small. They should not inspect `Cause` or
accept `unknown` unless they are final fallback helpers.
### 3. Convert One Vertical Slice
Start with session read routes because they already have local `mapNotFound`
logic and are heavily covered by existing HttpApi tests.
- Convert `Session.BusyError` from a plain `Error` to a typed service error, or
add a typed wrapper while preserving the old constructor until callers are
migrated.
- Replace `catchDefect` in `httpapi/handlers/session.ts` with typed error
mapping.
- Add endpoint error schemas for the affected session endpoints.
- Prove behavior with focused tests in `test/server/httpapi-session.test.ts`.
- Remove the migrated cases from the global compatibility middleware.
### 4. Convert Legacy NamedError Domains
Move legacy `NamedError.create(...)` services to Effect Schema-backed errors in
small domain PRs.
Priority order:
1. `storage/storage.ts` and `storage/db.ts` not-found errors.
2. `worktree/index.ts` `Worktree*` errors.
3. `provider/auth.ts` validation failures and `provider/provider.ts` model-not-found errors.
4. `mcp/index.ts`, `skill/index.ts`, `lsp/client.ts`, and `ide/index.ts` service errors.
5. Config and CLI-only errors after HTTP-facing domains are stable.
For each domain:
- Replace `NamedError.create(...)` with `Schema.TaggedErrorClass` when the error
is primarily a service error.
- Keep or add a separate HTTP error schema when the legacy `{ name, data }` wire
shape must remain stable.
- Update service interface return types to include the new error union.
- Replace `throw new X(...)` inside `Effect.fn` with `yield* new X(...)`.
- Replace async exceptions with `Effect.try({ catch })` or explicit `mapError`.
- Add service-level tests that assert the error tag and data, not just the HTTP
status.
### 5. Declare HttpApi Errors Group By Group
For each HttpApi group:
- Inventory every service call and the typed errors it can return.
- Add only the public error schemas that endpoint can actually emit.
- Map service errors to HTTP errors in the handler file.
- Keep built-in `HttpApiError` only for generic request/validation failures where
the generated contract is accepted.
- Update `httpapi/public.ts` compatibility transforms only when the generated
spec cannot represent the desired source shape directly.
- Regenerate the SDK after OpenAPI-visible changes and verify the diff is
intentional.
Suggested route order:
1. `session` not-found and busy-state reads.
2. `experimental` worktree mutations.
3. `provider` auth and model selection errors.
4. `mcp` OAuth and connection errors.
5. Remaining route groups as Hono deletion work progresses.
### 6. Remove Defect Recovery
After enough route groups declare their expected errors:
- Delete `catchDefect` recovery for domain errors.
- Delete name-prefix checks such as `error.name.startsWith("Worktree")` from
HTTP middleware.
- Delete `NamedError` branches from the Effect HttpApi compatibility middleware
once no Effect route depends on them.
- Leave one final unknown-defect fallback that logs server-side and returns a
safe generic `500` body.
## Inventory Checklist
Use this checklist when touching a service or route group.
- [ ] Does the service interface expose every expected failure in the Effect
error type?
- [ ] Are user-caused, provider-caused, IO, auth, missing-resource, and busy-state
failures modeled as typed errors instead of defects?
- [ ] Does the service avoid importing HTTP status, `HttpApiError`, or response
classes?
- [ ] Does the handler map each service error into a declared endpoint error?
- [ ] Does the endpoint `error` field include every public error the handler can
emit?
- [ ] Does OpenAPI/SDK output either stay byte-identical or have an explicitly
reviewed diff?
- [ ] Do tests cover both service-level error typing and HTTP-level status/body?
- [ ] Did the PR remove any now-unneeded case from the temporary compatibility
middleware?
## Testing Requirements
For service conversions:
- Test the service method directly with `testEffect(...)`.
- Assert on `_tag` or class identity and the structured fields.
- Avoid testing by string-matching `Cause.pretty(...)`.
For HttpApi conversions:
- Add or update the focused `test/server/httpapi-*.test.ts` file.
- Assert status code, content type, and exact JSON body for declared public
errors.
- Add a regression test that the temporary middleware is no longer needed for the
migrated route.
- Keep bridge/parity tests aligned with legacy Hono behavior until Hono is
deleted or the SDK contract intentionally changes.
## Verification Commands
Run from `packages/opencode` unless noted otherwise.
```bash
bun run prettier --write <changed files>
bunx oxlint <changed files>
bun typecheck
bun run test -- test/server/httpapi-session.test.ts
```
Run SDK generation from the repo root when schemas or OpenAPI-visible errors
change.
```bash
./packages/sdk/js/script/build.ts
```
## Open Questions
- Should legacy V1 routes keep `{ name, data }` forever while V2 routes expose a
more Effect-native tagged error body?
- Should storage not-found remain generic, or should callers map it to
domain-specific not-found errors before crossing service boundaries?
- Should `namedSchemaError(...)` stay as a long-term public-wire helper, or only
as a migration bridge for old `NamedError` contracts?
- Which SDK version boundary lets us stop remapping built-in Effect HttpApi error
schemas in `httpapi/public.ts`?
## Success Criteria
- New service code no longer uses `die` for expected failures.
- A route reviewer can read an endpoint definition and see every public error it
can return.
- The temporary HttpApi error middleware shrinks over time instead of gaining new
name-based cases.
- Service tests prove domain error types without going through HTTP.
- HTTP tests prove status/body contracts without relying on defect recovery.
+188 -69
View File
@@ -5,6 +5,8 @@ import {
type AuthenticateRequest,
type AuthMethod,
type CancelNotification,
type CloseSessionRequest,
type CloseSessionResponse,
type ForkSessionRequest,
type ForkSessionResponse,
type InitializeRequest,
@@ -51,9 +53,9 @@ import { LoadAPIKeyError } from "ai"
import type { AssistantMessage, Event, KiloClient, SessionMessageResponse, ToolPart } from "@kilocode/sdk/v2"
import { applyPatch } from "diff"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ShellID } from "@/tool/shell/id"
import { fetchDefaultModel } from "@kilocode/kilo-gateway" // kilocode_change
import { ShellID } from "@/tool/shell/id"
type ModeOption = { id: string; name: string; description?: string }
type ModelOption = { modelId: string; name: string }
@@ -569,6 +571,7 @@ export class Agent implements ACPAgent {
image: true,
},
sessionCapabilities: {
close: {},
fork: {},
list: {},
resume: {},
@@ -631,6 +634,9 @@ export class Agent implements ACPAgent {
// Store ACP session state
await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
const messages = await this.loadSessionMessages(directory, sessionId)
this.restoreSessionStateFromMessages(sessionId, messages)
log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
const result = await this.loadSessionMode({
@@ -639,39 +645,6 @@ export class Agent implements ACPAgent {
sessionId,
})
// Replay session history
const messages = await this.sdk.session
.messages(
{
sessionID: sessionId,
directory,
},
{ throwOnError: true },
)
.then((x) => x.data)
.catch((err) => {
log.error("unexpected error when fetching message", { error: err })
return undefined
})
const lastUser = messages?.findLast((m) => m.info.role === "user")?.info
if (lastUser?.role === "user") {
result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}`
this.sessionManager.setModel(sessionId, {
providerID: ProviderID.make(lastUser.model.providerID),
modelID: ModelID.make(lastUser.model.modelID),
})
if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) {
result.modes.currentModeId = lastUser.agent
this.sessionManager.setMode(sessionId, lastUser.agent)
}
result.configOptions = buildConfigOptions({
currentModelId: result.models.currentModelId,
availableModels: result.models.availableModels,
modes: result.modes,
})
}
for (const msg of messages ?? []) {
log.debug("replay message", msg)
await this.processMessage(msg)
@@ -760,6 +733,9 @@ export class Agent implements ACPAgent {
const sessionId = forked.id
await this.sessionManager.load(sessionId, directory, mcpServers, model)
const messages = await this.loadSessionMessages(directory, sessionId)
this.restoreSessionStateFromMessages(sessionId, messages)
log.info("fork_session", { sessionId, mcpServers: mcpServers.length })
const mode = await this.loadSessionMode({
@@ -768,20 +744,6 @@ export class Agent implements ACPAgent {
sessionId,
})
const messages = await this.sdk.session
.messages(
{
sessionID: sessionId,
directory,
},
{ throwOnError: true },
)
.then((x) => x.data)
.catch((err) => {
log.error("unexpected error when fetching message", { error: err })
return undefined
})
for (const msg of messages ?? []) {
log.debug("replay message", msg)
await this.processMessage(msg)
@@ -801,7 +763,7 @@ export class Agent implements ACPAgent {
}
}
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
const directory = params.cwd
const sessionId = params.sessionId
const mcpServers = params.mcpServers ?? []
@@ -810,6 +772,9 @@ export class Agent implements ACPAgent {
const model = await defaultModel(this.config, directory)
await this.sessionManager.load(sessionId, directory, mcpServers, model)
const messages = await this.loadSessionMessages(directory, sessionId, 20)
this.restoreSessionStateFromMessages(sessionId, messages)
log.info("resume_session", { sessionId, mcpServers: mcpServers.length })
const result = await this.loadSessionMode({
@@ -832,6 +797,27 @@ export class Agent implements ACPAgent {
}
}
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
const session = this.sessionManager.remove(params.sessionId)
if (!session) return {}
await this.sdk.session
.abort(
{
sessionID: params.sessionId,
directory: session.cwd,
},
{ throwOnError: true },
)
.catch((error) => {
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
})
this.permissionQueues.delete(params.sessionId)
log.info("close_session", { sessionId: params.sessionId })
return {}
}
private async processMessage(message: SessionMessageResponse) {
log.debug("process message", message)
if (message.info.role !== "assistant" && message.info.role !== "user") return
@@ -1163,23 +1149,26 @@ export class Agent implements ACPAgent {
sessionId: string,
): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> {
const availableModes = await this.loadAvailableModes(directory)
const currentModeId =
this.sessionManager.get(sessionId).modeId ||
(await (async () => {
if (!availableModes.length) return undefined
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId)
return resolvedModeId
})())
const storedModeId = this.sessionManager.get(sessionId).modeId
if (storedModeId && availableModes.some((mode) => mode.id === storedModeId)) {
return { availableModes, currentModeId: storedModeId }
}
const currentModeId = await (async () => {
if (!availableModes.length) return undefined
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId)
return resolvedModeId
})()
return { availableModes, currentModeId }
}
private async loadSessionMode(params: LoadSessionRequest) {
const directory = params.cwd
const model = await defaultModel(this.config, directory)
const sessionId = params.sessionId
const model = this.sessionManager.get(sessionId).model ?? (await defaultModel(this.config, directory))
const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
const entries = sortProvidersByName(providers)
@@ -1188,7 +1177,7 @@ export class Agent implements ACPAgent {
if (currentVariant && !availableVariants.includes(currentVariant)) {
this.sessionManager.setVariant(sessionId, undefined)
}
const availableModels = buildAvailableModels(entries, { includeVariants: true })
const availableModels = buildAvailableModels(entries)
const modeState = await this.resolveModeState(directory, sessionId)
const currentModeId = modeState.currentModeId
const modes = currentModeId
@@ -1271,13 +1260,15 @@ export class Agent implements ACPAgent {
return {
sessionId,
models: {
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
availableModels,
},
modes,
configOptions: buildConfigOptions({
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
availableModels,
currentVariant,
availableVariants,
modes,
}),
_meta: buildVariantMeta({
@@ -1300,6 +1291,24 @@ export class Agent implements ACPAgent {
const entries = sortProvidersByName(providers)
const availableVariants = modelVariantsFromProviders(entries, selection.model)
const modeState = await this.resolveModeState(session.cwd, session.id)
const modes = modeState.currentModeId
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
: undefined
await this.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "config_option_update",
configOptions: buildConfigOptions({
currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false),
availableModels: buildAvailableModels(entries),
currentVariant: selection.variant,
availableVariants,
modes,
}),
},
})
return {
_meta: buildVariantMeta({
@@ -1331,6 +1340,14 @@ export class Agent implements ACPAgent {
const selection = parseModelSelection(params.value, providers)
this.sessionManager.setModel(session.id, selection.model)
this.sessionManager.setVariant(session.id, selection.variant)
} else if (params.configId === "effort") {
if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string")
const current = session.model ?? (await defaultModel(this.config, session.cwd))
const availableVariants = modelVariantsFromProviders(entries, current)
if (!availableVariants.includes(params.value)) {
throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` }))
}
this.sessionManager.setVariant(session.id, params.value)
} else if (params.configId === "mode") {
if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
const availableModes = await this.loadAvailableModes(session.cwd)
@@ -1345,15 +1362,21 @@ export class Agent implements ACPAgent {
const updatedSession = this.sessionManager.get(session.id)
const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
const availableVariants = modelVariantsFromProviders(entries, model)
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true)
const availableModels = buildAvailableModels(entries, { includeVariants: true })
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false)
const availableModels = buildAvailableModels(entries)
const modeState = await this.resolveModeState(session.cwd, session.id)
const modes = modeState.currentModeId
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
: undefined
return {
configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
configOptions: buildConfigOptions({
currentModelId,
availableModels,
currentVariant: updatedSession.variant,
availableVariants,
modes,
}),
}
}
@@ -1550,6 +1573,37 @@ export class Agent implements ACPAgent {
{ throwOnError: true },
)
}
private async loadSessionMessages(directory: string, sessionId: string, limit?: number) {
return this.sdk.session
.messages(
{
sessionID: sessionId,
directory,
limit,
},
{ throwOnError: true },
)
.then((x) => x.data)
.catch((error) => {
log.error("unexpected error when fetching message", { error })
return undefined
})
}
private restoreSessionStateFromMessages(sessionId: string, messages: SessionMessageResponse[] | undefined) {
const lastUser = messages?.findLast((message) => message.info.role === "user")?.info
if (lastUser?.role !== "user") return
this.sessionManager.setModel(sessionId, {
providerID: ProviderID.make(lastUser.model.providerID),
modelID: ModelID.make(lastUser.model.modelID),
})
this.sessionManager.setVariant(sessionId, lastUser.model.variant)
if (lastUser.agent) {
this.sessionManager.setMode(sessionId, lastUser.agent)
}
}
}
function toToolKind(toolName: string): ToolKind {
@@ -1634,6 +1688,9 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
if (specified && !providers.length) return specified
// kilocode_change start
const lastUsed = await lastUsedModel(sdk, directory, providers)
if (lastUsed) return lastUsed
const kiloProvider = providers.find((p) => p.id === "kilo")
if (kiloProvider) {
const [best] = Provider.sort(Object.values(kiloProvider.models))
@@ -1657,7 +1714,7 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
if (specified) return specified
// kilocode_change start
// kilocode_change start - prefer kilo free-tier default before erroring.
// Only fall back to the Kilo provider if it was present in the available
// providers list. When teams configure enabled_providers to use only their
// own models, this prevents silently routing requests to an external API.
@@ -1667,8 +1724,39 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
const freeModel = await fetchDefaultModel()
return { providerID: ProviderID.kilo, modelID: ModelID.make(freeModel) }
}
throw new Error("no model available: no providers are configured and no default model is set")
// kilocode_change end
throw new Error("No models available")
}
async function lastUsedModel(
sdk: KiloClient,
directory: string,
providers: Array<{ id: string; models: Record<string, unknown> }>,
): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> {
const session = await sdk.session
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
.then((x) => x.data?.[0])
.catch((error) => {
log.error("failed to list sessions for default model", { error })
return undefined
})
if (!session) return
const lastUser = await sdk.session
.messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true })
.then((x) => x.data?.findLast((message) => message.info.role === "user")?.info)
.catch((error) => {
log.error("failed to load session messages for default model", { error, sessionID: session.id })
return undefined
})
if (lastUser?.role !== "user") return
const provider = providers.find((entry) => entry.id === lastUser.model.providerID)
if (!provider?.models[lastUser.model.modelID]) return
return {
providerID: ProviderID.make(lastUser.model.providerID),
modelID: ModelID.make(lastUser.model.modelID),
}
}
function parseUri(
@@ -1771,8 +1859,14 @@ function formatModelIdWithVariant(
includeVariant: boolean,
) {
const base = `${model.providerID}/${model.modelID}`
if (!includeVariant || !variant || !availableVariants.includes(variant)) return base
return `${base}/${variant}`
if (!includeVariant || availableVariants.length === 0) return base
const selectedVariant =
variant && availableVariants.includes(variant)
? variant
: availableVariants.includes(DEFAULT_VARIANT_VALUE)
? DEFAULT_VARIANT_VALUE
: availableVariants[0]
return `${base}/${selectedVariant}`
}
function buildVariantMeta(input: {
@@ -1824,6 +1918,8 @@ function parseModelSelection(
function buildConfigOptions(input: {
currentModelId: string
availableModels: ModelOption[]
currentVariant?: string
availableVariants?: string[]
modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
}): SessionConfigOption[] {
const options: SessionConfigOption[] = [
@@ -1836,6 +1932,22 @@ function buildConfigOptions(input: {
options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
},
]
if (input.availableVariants?.length) {
options.push({
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue:
input.currentVariant && input.availableVariants.includes(input.currentVariant)
? input.currentVariant
: input.availableVariants.includes(DEFAULT_VARIANT_VALUE)
? DEFAULT_VARIANT_VALUE
: input.availableVariants[0],
options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
})
}
if (input.modes) {
options.push({
id: "mode",
@@ -1853,4 +1965,11 @@ function buildConfigOptions(input: {
return options
}
function formatVariantName(variant: string) {
return variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ")
}
export * as ACP from "./agent"
+6
View File
@@ -113,4 +113,10 @@ export class ACPSessionManager {
this.sessions.set(sessionId, session)
return session
}
remove(sessionId: string): ACPSessionState | undefined {
const session = this.sessions.get(sessionId)
this.sessions.delete(sessionId)
return session
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ export const AcpCommand = effectCmd({
},
handler: Effect.fn("Cli.acp")(function* (args) {
process.env.KILO_CLIENT = "acp"
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
const sdk = createKiloClient({
+2 -2
View File
@@ -84,7 +84,7 @@ const AgentCreateCommand = effectCmd({
// Determine scope/path
let targetPath: string
if (cliPath) {
targetPath = path.join(cliPath, "agent")
targetPath = path.join(cliPath, "agents")
} else {
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
@@ -108,7 +108,7 @@ const AgentCreateCommand = effectCmd({
}
targetPath = path.join(
scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".kilo"), // kilocode_change
"agent",
"agents",
)
}
+1 -1
View File
@@ -16,7 +16,7 @@ export const ServeCommand = effectCmd({
if (!Flag.KILO_SERVER_PASSWORD) {
console.log("Warning: KILO_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
console.log(`kilo server listening on http://${server.hostname}:${server.port}`) // kilocode_change
+4 -3
View File
@@ -9,6 +9,7 @@ import { Locale } from "@/util/locale"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { NotFoundError } from "@/storage/storage"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
@@ -59,9 +60,9 @@ export const SessionDeleteCommand = effectCmd({
handler: Effect.fn("Cli.session.delete")(function* (args) {
const svc = yield* Session.Service
const sessionID = SessionID.make(args.sessionID)
// Match legacy try/catch — Session.get surfaces NotFoundError as a defect.
yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`)))
yield* svc.remove(sessionID)
yield* svc
.remove(sessionID)
.pipe(Effect.catchIf(NotFoundError.isInstance, () => fail(`Session not found: ${args.sessionID}`)))
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
}),
})
@@ -19,6 +19,60 @@ import { useConnected } from "./use-connected"
const PROVIDER_PRIORITY: Record<string, number> = KiloProvider.PROVIDER_PRIORITY // kilocode_change
const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__"
const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
type ProviderOptionBase = {
title: string
value: string
description?: string
category: string
}
type ProviderOption =
| (ProviderOptionBase & {
type: "provider"
providerID: string
})
| (ProviderOptionBase & {
type: "custom"
})
export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] {
return [
...pipe(
list,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => ({
type: "provider" as const,
title: provider.name,
value: provider.id,
providerID: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers",
})),
),
{
type: "custom",
title: "Other",
value: CUSTOM_PROVIDER_OPTION_VALUE,
description: "Custom provider",
category: "Providers",
},
]
}
export function normalizeCustomProviderID(value: string) {
const providerID = value.trim().replace(/^@ai-sdk\//, "")
if (!CUSTOM_PROVIDER_ID.test(providerID)) return
return providerID
}
export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
@@ -26,13 +80,50 @@ export function createDialogProviderOptions() {
const toast = useToast()
const { theme } = useTheme()
const onboarded = useConnected()
async function promptCustomProviderID(): Promise<string | undefined> {
const value = await DialogPrompt.show(dialog, "Other", {
placeholder: "Provider id",
description: () => (
<text fg={theme.textMuted}>
This only stores a credential. Configure the provider in opencode.json to use it.
</text>
),
})
if (value === null) return
const providerID = normalizeCustomProviderID(value)
if (providerID) return providerID
toast.show({
variant: "error",
message:
"Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores",
})
return promptCustomProviderID()
}
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
providerOptions(sync.data.provider_next.all),
map((provider) => {
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id)
const connected = sync.data.provider_next.connected.includes(provider.id)
if (provider.type === "custom") {
return {
title: provider.title,
value: provider.value,
description: provider.description,
category: provider.category,
async onSelect() {
const providerID = await promptCustomProviderID()
if (!providerID) return
return dialog.replace(() => <ApiMethod providerID={providerID} title="API key" custom />)
},
}
}
const providerID = provider.providerID
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID)
const connected = sync.data.provider_next.connected.includes(providerID)
// kilocode_change start
const failed = sync.data.provider_next.failed ?? []
const failedGutter = KiloProvider.renderGutter(provider.id, failed, theme)
@@ -41,16 +132,16 @@ export function createDialogProviderOptions() {
// kilocode_change end
return {
title: KiloProvider.PROVIDER_TITLES[provider.id] ?? provider.name, // kilocode_change
value: provider.id,
description: failedDesc ?? baseDesc, // kilocode_change
title: KiloProvider.PROVIDER_TITLES[provider.id] ?? provider.title, // kilocode_change
value: provider.value,
description: failedDesc ?? baseDesc ?? provider.description, // kilocode_change
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
category: provider.category,
gutter: failedGutter ?? (connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined), // kilocode_change
async onSelect() {
if (consoleManaged) return
const methods = sync.data.provider_auth[provider.id] ?? [
const methods = sync.data.provider_auth[providerID] ?? [
{
type: "api",
label: "API key",
@@ -88,7 +179,7 @@ export function createDialogProviderOptions() {
}
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
providerID,
method: index,
inputs,
})
@@ -102,12 +193,7 @@ export function createDialogProviderOptions() {
}
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
<CodeMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
))
}
if (result.data?.method === "auto") {
@@ -126,12 +212,7 @@ export function createDialogProviderOptions() {
} else {
// kilocode_change end
dialog.replace(() => (
<AutoMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
<AutoMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
))
} // kilocode_change
}
@@ -144,7 +225,7 @@ export function createDialogProviderOptions() {
metadata = value
}
return dialog.replace(() => (
<ApiMethod providerID={provider.id} title={method.label} metadata={metadata} />
<ApiMethod providerID={providerID} title={method.label} metadata={metadata} />
))
}
},
@@ -266,11 +347,13 @@ interface ApiMethodProps {
providerID: string
title: string
metadata?: Record<string, string>
custom?: boolean
}
function ApiMethod(props: ApiMethodProps) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const toast = useToast()
const { theme } = useTheme()
return (
@@ -290,6 +373,14 @@ function ApiMethod(props: ApiMethodProps) {
})
await sdk.client.instance.dispose()
await sync.bootstrap()
if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) {
toast.show({
variant: "info",
message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`,
})
dialog.clear()
return
}
dialog.replace(() => <DialogModel providerID={props.providerID} />)
}}
/>
@@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
@@ -10,16 +10,14 @@ import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
import path from "path" // kilocode_change
import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
import { WorkspaceLabel } from "./workspace-label"
export function DialogSessionList() {
const dialog = useDialog()
@@ -67,26 +65,41 @@ export function DialogSessionList() {
})
// kilocode_change end
function createWorkspace() {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
}
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
const warp = async (selection: WorkspaceSelection) => {
const workspaceID = await (async () => {
if (selection.type === "none") return null
if (selection.type === "existing") return selection.workspaceID
const result = await sdk.client.experimental.workspace
.create({ type: selection.workspaceType, branch: null })
.catch(() => undefined)
const workspace = result?.data
if (!workspace) {
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
await project.workspace.sync()
return workspace.id
})()
if (workspaceID === undefined) return
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID: session.workspaceID,
workspaceID,
sessionID: session.id,
copyChanges: false,
done: list,
})
}
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
@@ -113,22 +126,15 @@ export function DialogSessionList() {
return true
}}
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
/>
))
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warp(selection)
},
})
return false
}}
/>
@@ -148,30 +154,17 @@ export function DialogSessionList() {
.map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let workspaceStatus: WorkspaceStatus | null = null
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
let footer: JSX.Element | string = ""
if (Flag.KILO_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) {
let desc = "unknown"
if (workspace) {
desc = `${workspace.type}: ${workspace.name}`
}
footer = (
<>
{desc}{" "}
<span
style={{
fg: workspaceStatus === "connected" ? theme.success : theme.error,
}}
>
●
</span>
</>
footer = workspace ? (
<WorkspaceLabel
type={workspace.type}
name={workspace.name}
status={project.workspace.status(x.workspaceID) ?? "error"}
/>
) : (
<WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
)
}
} else {
@@ -294,15 +287,6 @@ export function DialogSessionList() {
},
},
// kilocode_change end
{
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
side: "right",
disabled: !Flag.KILO_EXPERIMENTAL_WORKSPACES,
onTrigger: () => {
createWorkspace()
},
},
]}
/>
)
@@ -1,14 +1,15 @@
import { createKiloClient } from "@kilocode/sdk/v2"
import type { Workspace } from "@kilocode/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { useRoute } from "@tui/context/route"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
type Adapter = {
type: string
@@ -16,184 +17,290 @@ type Adapter = {
description: string
}
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createKiloClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
export type WorkspaceSelection =
| {
type: "none"
}
| {
type: "new"
workspaceType: string
workspaceName: string
}
| {
type: "existing"
workspaceID: string
workspaceType: string
workspaceName: string
}
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
type ExistingWorkspaceSelectValue = { workspace: Workspace }
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string }>(input: {
sessions: readonly { workspaceID?: string; time: { updated: number } }[]
get: (workspaceID: string) => WorkspaceInfo | undefined
status: (workspaceID: string) => string | undefined
limit?: number
omitWorkspaceID?: string
}) {
const workspaces = input.sessions
.toSorted((a, b) => b.time.updated - a.time.updated)
.flatMap((session) => {
const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined
return workspace && input.status(workspace.id) === "connected" ? [workspace] : []
})
.filter((workspace) => workspace.id !== input.omitWorkspaceID)
.filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index)
const recent = workspaces.slice(0, input.limit ?? 3)
return { recent, hasMore: recent.length < workspaces.length }
}
export async function openWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
export function warpReminderText(dir: string) {
return `<system-reminder>The user has changed the current working directory to "${dir}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
}
async function loadWorkspaceAdapters(input: {
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
while (true) {
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
input.dialog.clear()
return
}
const dir = input.sync.path.directory || input.sdk.directory
const url = new URL("/experimental/workspace/adapter", input.sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await input.sdk
.fetch(url)
.then((x) => x.json() as Promise<Adapter[]>)
.catch(() => undefined)
if (res) return res
input.toast.show({
message: "Failed to load workspace adapters",
variant: "error",
})
}
export async function restoreWorkspaceSession(input: {
export async function openWorkspaceSelect(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
input.dialog.clear()
const adapters = await loadWorkspaceAdapters(input)
if (!adapters) return
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
}
export async function warpWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
workspaceID: string
sourceWorkspaceID?: string
workspaceID: string | null
sessionID: string
copyChanges: boolean
done?: () => void
}) {
}): Promise<boolean> {
const result = await input.sdk.client.experimental.workspace
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
.warp({
id: input.workspaceID,
sessionID: input.sessionID,
copyChanges: input.copyChanges,
})
.catch(() => undefined)
if (!result?.data) {
if (result?.error?.name === "VcsApplyError") {
await DialogAlert.show(
input.dialog,
"Unable to Warp Session",
"Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.",
)
return false
}
input.toast.show({
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
return false
}
input.project.workspace.set(input.workspaceID)
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
const dir = input.project.instance.directory() || input.sync.path.directory
if (dir) {
await input.sdk.client.session
.promptAsync({
sessionID: input.sessionID,
workspace: input.workspaceID ?? undefined,
noReply: true,
parts: [
{
type: "text",
text: warpReminderText(dir),
synthetic: true,
},
],
})
.catch(() => undefined)
}
input.toast.show({
message: "Session restored into the new workspace",
variant: "success",
})
input.done?.()
if (input.done) return
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
if (input.done) {
input.done()
return true
}
input.dialog.clear()
return true
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
export async function confirmWorkspaceFileChanges(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sourceWorkspaceID?: string
}) {
const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined)
const fileChangeChoice = status?.data?.length
? await DialogWorkspaceFileChanges.show(input.dialog, status.data)
: "no"
if (!fileChangeChoice) return
return fileChangeChoice === "yes"
}
export function DialogWorkspaceSelect(props: {
adapters?: Adapter[]
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const dialog = useDialog()
const sync = useSync()
const project = useProject()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
const [adapters, setAdapters] = createSignal<Adapter[]>()
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined))
onMount(() => {
dialog.setSize("medium")
void (async () => {
const dir = sync.path.directory || sdk.directory
const url = new URL("/experimental/workspace/adapter", sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adapter[]>)
.catch(() => undefined)
if (!res) {
toast.show({
message: "Failed to load workspace adapters",
variant: "error",
})
return
}
if (adapters()) return
const res = await loadWorkspaceAdapters({ sdk, sync, toast })
if (!res) return
setAdapters(res)
})()
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
const list = adapters()
if (!list) {
return [
{
title: "Loading workspaces...",
value: "loading" as const,
description: "Fetching available workspace adapters",
if (!list) return []
const { recent, hasMore } = recentConnectedWorkspaces({
sessions: sync.data.session,
get: project.workspace.get,
status: project.workspace.status,
omitWorkspaceID: omittedWorkspaceID(),
})
return [
...list.map((adapter) => ({
title: adapter.name,
value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
description: adapter.description,
category: "New workspace",
})),
{
title: "None",
value: { type: "none" as const },
description: "Use the local project",
category: "Choose workspace",
},
...recent.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: {
type: "existing" as const,
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
},
]
}
return list.map((item) => ({
title: item.name,
value: item.type,
description: item.description,
}))
category: "Choose workspace",
})),
...(hasMore
? [
{
title: "View all workspaces",
value: { type: "existing-list" as const },
description: "Choose from all workspaces",
category: "Choose workspace",
},
]
: []),
]
})
const create = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
toast.show({
message: "Creating workspace failed",
variant: "error",
})
return undefined
})
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
await project.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
if (!adapters()) return null
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
<DialogSelect<WorkspaceSelectValue>
title="Warp"
skipFilter={true}
renderFilter={false}
options={options()}
onSelect={(option) => {
if (option.value === "creating" || option.value === "loading") return
void create(option.value)
if (!option.value) return
if (option.value.type === "none") {
void props.onSelect(option.value)
return
}
if (option.value.type === "new") {
void props.onSelect(option.value)
return
}
if (option.value.type === "existing") {
void props.onSelect(option.value)
return
}
dialog.replace(() => (
<DialogExistingWorkspaceSelect omitWorkspaceID={omittedWorkspaceID()} onSelect={props.onSelect} />
))
}}
/>
)
}
function DialogExistingWorkspaceSelect(props: {
omitWorkspaceID?: string
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const project = useProject()
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
project.workspace
.list()
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
.filter((workspace) => workspace.id !== props.omitWorkspaceID)
.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: { workspace },
})),
)
return (
<DialogSelect<ExistingWorkspaceSelectValue>
title="Existing Workspace"
options={options()}
onSelect={(option) => {
void props.onSelect({
type: "existing",
workspaceID: option.value.workspace.id,
workspaceType: option.value.workspace.type,
workspaceName: option.value.workspace.name,
})
}}
/>
)
@@ -0,0 +1,138 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import type { VcsFileStatus } from "@kilocode/sdk/v2"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { Locale } from "@/util/locale"
import { useTheme } from "../context/theme"
import { useTuiConfig } from "../context/tui-config"
import { useDialog, type DialogContext } from "../ui/dialog"
import { getScrollAcceleration } from "../util/scroll"
const options = ["no", "yes"] as const
export type WorkspaceFileChangesChoice = (typeof options)[number]
function statusLabel(status: VcsFileStatus["status"]) {
if (status === "added") return "A"
if (status === "deleted") return "D"
return "M"
}
function changeCountWidth(file: VcsFileStatus) {
// The "plus 2" is for spaces
return `${file.additions ? `+${file.additions}` : ""}${file.deletions ? ` -${file.deletions}` : ""}`.length + 2
}
export function DialogWorkspaceFileChanges(props: {
files: VcsFileStatus[]
onSelect: (choice: WorkspaceFileChangesChoice) => void
}) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
const height = createMemo(() => Math.min(props.files.length, 8))
const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
function confirm() {
props.onSelect(store.active)
dialog.clear()
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
confirm()
return
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
const index = options.indexOf(store.active)
setStore("active", options[Math.max(index - 1, 0)])
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
const index = options.indexOf(store.active)
setStore("active", options[Math.min(index + 1, options.length - 1)])
}
})
return (
<box gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
File Changes Found
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<scrollbox
height={height()}
backgroundColor={theme.backgroundElement}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
>
<For each={props.files}>
{(item) => (
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<box flexDirection="row" minWidth={0} flexShrink={1}>
<box width={2} flexShrink={0}>
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
</box>
<text fg={theme.textMuted} wrapMode="none">
{Locale.truncateLeft(item.file, fileNameWidth())}
</text>
</box>
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
<text>
{" "}
{item.additions ? <span style={{ fg: theme.diffAdded }}>+{item.additions}</span> : null}
{item.deletions ? <span style={{ fg: theme.diffRemoved }}> -{item.deletions}</span> : null}
</text>
</box>
</box>
)}
</For>
</scrollbox>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.textMuted} wrapMode="word">
Do you want to apply these changes after warping?
</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
<For each={options}>
{(item) => (
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item)
props.onSelect(item)
dialog.clear()
}}
>
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
</box>
)}
</For>
</box>
</box>
)
}
DialogWorkspaceFileChanges.show = (dialog: DialogContext, files: VcsFileStatus[]) => {
return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
dialog.replace(
() => <DialogWorkspaceFileChanges files={files} onSelect={resolve} />,
() => resolve(undefined),
)
})
}
@@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem"
import { useLocal } from "@tui/context/local"
import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
@@ -41,10 +42,17 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import {
confirmWorkspaceFileChanges,
openWorkspaceSelect,
warpWorkspaceSession,
type WorkspaceSelection,
} 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 { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
export type PromptProps = {
sessionID?: string
@@ -171,11 +179,99 @@ export function Prompt(props: PromptProps) {
if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
})
const [editorContextHover, setEditorContextHover] = createSignal(false)
let lastSubmittedEditorSelectionKey: string | undefined
const editorContextLabelState = createMemo(() => editor.labelState())
const [auto, setAuto] = createSignal<AutocompleteRef>()
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
const [warpNotice, setWarpNotice] = createSignal<string>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current())
function selectWorkspace(selection: WorkspaceSelection | undefined) {
setWorkspaceSelection(selection)
}
function setCreatingWorkspace(creating: boolean) {
setWorkspaceCreating(creating)
}
function showWarpNotice(name: string) {
setWarpNotice(`Warped to ${name}`)
setTimeout(() => setWarpNotice(undefined), 4000)
}
async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
setCreatingWorkspace(true)
const result = await sdk.client.experimental.workspace
.create({ type: selection.workspaceType, branch: null })
.catch(() => undefined)
if (result == undefined || result.error || !result.data) {
selectWorkspace(undefined)
setCreatingWorkspace(false)
toast.show({
message: "Creating workspace failed",
variant: "error",
})
return
}
await project.workspace.sync()
const workspace = result.data
selectWorkspace({
type: "existing",
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
})
setCreatingWorkspace(false)
return workspace
}
async function warpSession(selection: WorkspaceSelection) {
if (!props.sessionID) {
selectWorkspace(selection)
dialog.clear()
if (selection.type === "new") void createWorkspace(selection)
return
}
const sourceWorkspaceID = project.workspace.current()
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
if (copyChanges === undefined) return
selectWorkspace(selection)
dialog.clear()
const workspace =
selection.type === "none"
? { id: null, name: "local project" }
: selection.type === "existing"
? { id: selection.workspaceID, name: selection.workspaceName }
: await createWorkspace(selection)
if (!workspace) return
const warped = await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID,
workspaceID: workspace.id,
sessionID: props.sessionID,
copyChanges,
})
if (warped) showWarpNotice(workspace.name)
}
createEffect(() => {
if (!workspaceCreating()) {
setWorkspaceCreatingDots(3)
return
}
const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
function promptModelWarning() {
toast.show({
@@ -214,6 +310,7 @@ export function Prompt(props: PromptProps) {
})
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
})
@@ -497,6 +594,27 @@ export function Prompt(props: PromptProps) {
))
},
},
{
title: "Warp",
description: "Change the workspace for the session",
value: "workspace.set",
category: "Session",
enabled: Flag.KILO_EXPERIMENTAL_WORKSPACES,
slash: {
name: "warp",
},
onSelect: (dialog) => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
},
},
]
})
@@ -715,6 +833,8 @@ export function Prompt(props: PromptProps) {
])
async function submit() {
setWarpNotice(undefined)
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
@@ -723,6 +843,7 @@ export function Prompt(props: PromptProps) {
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (workspaceCreating()) return false
if (autocomplete?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
@@ -745,21 +866,16 @@ export function Prompt(props: PromptProps) {
dialog.replace(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(nextWorkspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: nextWorkspaceID,
sessionID: props.sessionID!,
})
}
/>
))
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
return false
}}
/>
))
@@ -769,8 +885,16 @@ export function Prompt(props: PromptProps) {
const variant = local.model.variant.current()
let sessionID = props.sessionID
if (sessionID == null) {
const workspace = workspaceSelection()
const workspaceID = iife(() => {
if (!workspace) return defaultWorkspaceID()
if (workspace.type === "none") return undefined
if (workspace.type === "existing") return workspace.workspaceID
return undefined
})
const res = await sdk.client.session.create({
workspace: props.workspaceID,
workspace: workspaceID,
agent: agent.name,
model: {
providerID: selectedModel.providerID,
@@ -818,9 +942,8 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const editorSelection = editorContext()
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
const editorParts =
editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey
editorSelection && editor.labelState() === "pending"
? [
{
id: PartID.ascending(),
@@ -898,7 +1021,7 @@ export function Prompt(props: PromptProps) {
],
})
.catch(() => {})
lastSubmittedEditorSelectionKey = currentEditorSelectionKey
if (editorParts.length > 0) editor.markSelectionSent()
}
toast.dismiss() // kilocode_change - dismiss persistent config warning on first submit
history.append({
@@ -914,13 +1037,15 @@ export function Prompt(props: PromptProps) {
props.onSubmit?.()
// temporary hack to make sure the message is sent
if (!props.sessionID)
if (!props.sessionID) {
if (editorParts.length > 0) editor.preserveSelectionFromNewSession()
setTimeout(() => {
route.navigate({
type: "session",
sessionID,
})
}, 50)
}
input.clear()
return true
}
@@ -1042,6 +1167,39 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const workspaceLabel = createMemo<
| { type: "new"; workspaceType: string }
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
| undefined
>(() => {
const selected = workspaceSelection()
if (!selected) {
const workspaceID = defaultWorkspaceID()
if (props.sessionID || !workspaceID) return
const workspace = project.workspace.get(workspaceID)
return {
type: "existing",
workspaceType: workspace?.type ?? "unknown",
workspaceName: workspace?.name ?? workspaceID,
status: project.workspace.status(workspaceID) ?? "error",
}
}
if (selected.type === "none") return
if (props.sessionID && !workspaceCreating()) return
if (selected.type === "new") {
return {
type: "new",
workspaceType: selected.workspaceType,
}
}
return {
type: "existing",
workspaceType: selected.workspaceType,
workspaceName: selected.workspaceName,
status: selected.type === "existing" ? "connected" : undefined,
}
})
const spinnerDef = createMemo(() => {
const agent = local.agent.current()
const color = agent ? local.agent.color(agent.name ?? "") : theme.border // kilocode_change
@@ -1318,7 +1476,7 @@ export function Prompt(props: PromptProps) {
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.text}
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1394,86 +1552,124 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
<Switch>
<Match when={status().type !== "idle"}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Show>
</Match>
<Match when={warpNotice()}>
{(notice) => (
<box paddingLeft={3}>
<text fg={theme.accent}>{notice()}</text>
</box>
)}
</Match>
<Match when={workspaceLabel()}>
{(workspace) => (
<box paddingLeft={3} flexDirection="row" gap={1}>
<Show when={workspaceCreating()}>
<Spinner color={theme.accent} />
</Show>
<text fg={workspaceCreating() ? theme.accent : theme.text}>
{(() => {
const item = workspace()
if (item.type === "new") {
if (workspaceCreating())
return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
</>
)
}
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
</>
)
})()}
</text>
</box>
)}
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
</Switch>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
{/* kilocode_change start - show "ctrl+c again to exit" hint */}
@@ -1483,16 +1679,9 @@ export function Prompt(props: PromptProps) {
</text>
</Show>
{/* kilocode_change end */}
<Show when={editorFileLabelDisplay()}>
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
<text
fg={theme.secondary}
onMouseOver={() => setEditorContextHover(true)}
onMouseOut={() => setEditorContextHover(false)}
onMouseUp={dismissEditorContext}
>
{editorContextHover() ? `x ${file()}` : file()}
</text>
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
)}
</Show>
<Switch>
@@ -0,0 +1,19 @@
import { useTheme } from "@tui/context/theme"
export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
const { theme } = useTheme()
const color = () => {
if (props.status === "connected") return theme.success
if (props.status === "error") return theme.error
return theme.textMuted
}
return (
<>
{props.icon ? <span style={{ fg: color() }}>● </span> : undefined}
<span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
</>
)
}
@@ -87,6 +87,7 @@ const EditorServerInfoSchema = z.object({
type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
export type EditorSelection = z.infer<typeof EditorSelectionSchema>
export type EditorMention = z.infer<typeof EditorMentionSchema>
export type EditorLabelState = "pending" | "sent" | "none"
type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
type EditorConnection = {
@@ -111,10 +112,12 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined
selectionSent: boolean
server: EditorServerInfo | undefined
}>({
status: "disabled",
selection: undefined,
selectionSent: false,
server: undefined,
})
@@ -126,8 +129,24 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
let directory = process.cwd()
let preserveSelectionOnReconnect = false
const pending = new Map<number, string>()
const setSelection = (selection: EditorSelection | undefined) => {
const changed = editorSelectionKey(selection) !== editorSelectionKey(store.selection)
setStore("selection", selection)
if (changed) setStore("selectionSent", false)
}
const clearSelectionForReconnect = (options?: { resetZedSelectionKey?: boolean }) => {
if (preserveSelectionOnReconnect) {
preserveSelectionOnReconnect = false
return
}
if (options?.resetZedSelectionKey) lastZedSelectionKey = undefined
setSelection(undefined)
}
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== 1) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
@@ -158,7 +177,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const key = editorSelectionKey(selection)
if (key !== lastZedSelectionKey) {
lastZedSelectionKey = key
setStore("selection", selection)
setSelection(selection)
setStore("status", selection ? "connected" : "disabled")
}
})
@@ -198,7 +217,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", { ...selection.data, source: "websocket" })
setSelection({ ...selection.data, source: "websocket" })
return
}
@@ -252,12 +271,13 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const reconnectWithDirectory = (nextDirectory?: string) => {
const resolved = nextDirectory || process.cwd()
if (directory === resolved) return
const sameDirectory = directory === resolved
clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory })
if (sameDirectory) return
directory = resolved
attempt = 0
pending.clear()
lastZedSelectionKey = undefined
if (reconnect) clearTimeout(reconnect)
reconnect = undefined
if (socket) {
@@ -266,7 +286,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
current.close()
}
setStore("status", "disabled")
setStore("selection", undefined)
setStore("server", undefined)
connect()
}
@@ -293,7 +312,19 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
},
clearSelection() {
lastZedSelectionKey = undefined
setStore("selection", undefined)
zedSelection = undefined
setSelection(undefined)
},
preserveSelectionFromNewSession() {
preserveSelectionOnReconnect = true
},
markSelectionSent() {
if (!store.selection) return
setStore("selectionSent", true)
},
labelState(): EditorLabelState {
if (!store.selection) return "none"
return store.selectionSent ? "sent" : "pending"
},
onMention(listener: (mention: EditorMention) => void) {
mentionListeners.add(listener)
@@ -303,7 +334,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return store.server
},
reconnect(directory?: string) {
setStore("selection", undefined)
reconnectWithDirectory(directory)
},
}
@@ -464,20 +464,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
}
// kilocode_change - validate configured agent model when agent changes
createEffect(() => {
// kilocode_change start - configured models resolve directly without persistence
if (!model.ready) return
const value = agent.current()
if (!value) return // guard against empty agent list during org switch
if (!value.model) return
if (!value?.model) return
if (isModelValid(value.model)) return
toast.show({
variant: "warning",
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
duration: 3000,
})
// kilocode_change end
})
const result = {
@@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
function activeAssistant(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
if (index < 0) return
const assistant = messages[index]
return assistant?.type === "assistant" ? assistant : undefined
}
function activeCompaction(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "compaction")
const index = messages.findIndex((message) => message.type === "compaction")
if (index < 0) return
const compaction = messages[index]
return compaction?.type === "compaction" ? compaction : undefined
}
function activeShell(messages: SessionMessage[], callID: string) {
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
if (index < 0) return
const shell = messages[index]
return shell?.type === "shell" ? shell : undefined
@@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
switch (event.type) {
case "session.next.prompted": {
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "user",
text: event.properties.prompt.text,
@@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
}
case "session.next.synthetic":
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "synthetic",
sessionID: event.properties.sessionID,
@@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.shell.started":
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "shell",
callID: event.properties.callID,
@@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
update(event.properties.sessionID, (draft) => {
const currentAssistant = activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
draft.push({
draft.unshift({
id: event.id,
type: "assistant",
agent: event.properties.agent,
@@ -259,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.compaction.started":
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "compaction",
reason: event.properties.reason,
@@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { SyntaxStyle } from "@opentui/core"
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { Locale } from "@/util/locale"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import path from "path"
@@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
const renderedMessages = createMemo(() => messages().toReversed())
const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
const lastUserCreated = (index: number) =>
renderedMessages()
.slice(0, index)
.findLast((message) => message.type === "user")?.time.created
createEffect(() => {
void sync.session.message.sync(props.sessionID)
@@ -83,10 +87,11 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
last={lastAssistant()?.id === message.id}
syntax={syntax()}
subtleSyntax={subtleSyntax()}
start={lastUserCreated(index())}
/>
</Match>
<Match when={message.type === "synthetic"}>
<SyntheticMessage message={message as SessionMessageSynthetic} index={index()} />
<></>
</Match>
<Match when={message.type === "shell"}>
<ShellMessage message={message as SessionMessageShell} />
@@ -146,63 +151,36 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
<box
id={props.message.id}
border={["left"]}
borderColor={theme.primary}
borderColor={theme.secondary}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
flexShrink={0}
>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={theme.backgroundPanel}>
<Show
when={props.message.text.trim()}
fallback={
<MissingData label="User message text" detail={`Message ${props.message.id} has no text field content.`} />
}
>
<text fg={theme.text}>{props.message.text}</text>
</Show>
<Show when={attachments().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={props.message.files ?? []}>
{(file) => (
<text fg={theme.text}>
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
</text>
)}
</For>
<For each={props.message.agents ?? []}>
{(agent) => (
<text fg={theme.text}>
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
</text>
)}
</For>
</box>
</Show>
<text fg={theme.textMuted}>{Locale.todayTimeOrDateTime(props.message.time.created)}</text>
</box>
</box>
)
}
function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) {
const { theme } = useTheme()
return (
<box
id={props.message.id}
border={["left"]}
borderColor={theme.backgroundElement}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
paddingLeft={2}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.backgroundPanel}
flexShrink={0}
>
<text fg={theme.textMuted}>Synthetic</text>
<text fg={theme.text}>{props.message.text}</text>
<Show when={attachments().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={props.message.files ?? []}>
{(file) => (
<text fg={theme.text}>
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
</text>
)}
</For>
<For each={props.message.agents ?? []}>
{(agent) => (
<text fg={theme.text}>
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
</text>
)}
</For>
</box>
</Show>
</box>
)
}
@@ -237,7 +215,7 @@ function ShellMessage(props: { message: SessionMessageShell }) {
}
function CompactionMessage(props: { message: SessionMessageCompaction }) {
const { theme } = useTheme()
const { theme, syntax } = useTheme()
return (
<box
marginTop={1}
@@ -248,7 +226,19 @@ function CompactionMessage(props: { message: SessionMessageCompaction }) {
flexShrink={0}
>
<Show when={props.message.summary}>
<text fg={theme.textMuted}>{props.message.summary}</text>
{(summary) => (
<box paddingLeft={3} paddingTop={1}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={false}
syntaxStyle={syntax()}
content={summary().trim()}
conceal={true}
fg={theme.text}
/>
</box>
)}
</Show>
</box>
)
@@ -294,12 +284,13 @@ function AssistantMessage(props: {
last: boolean
syntax: SyntaxStyle
subtleSyntax: SyntaxStyle
start?: number
}) {
const { theme } = useTheme()
const local = useLocal()
const duration = createMemo(() => {
if (!props.message.time.completed) return 0
return props.message.time.completed - props.message.time.created
return props.message.time.completed - (props.start ?? props.message.time.created)
})
const model = createMemo(() => {
const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
@@ -361,7 +352,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
const { theme } = useTheme()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} marginTop={1} flexShrink={0}>
<box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
<code
filetype="markdown"
drawUnstyledText={false}
@@ -521,33 +512,93 @@ function InlineTool(props: {
part: SessionMessageAssistantTool
}) {
const { theme } = useTheme()
const renderer = useRenderer()
const [margin, setMargin] = createSignal(0)
const [hover, setHover] = createSignal(false)
const [showError, setShowError] = createSignal(false)
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
const complete = createMemo(() => !!props.complete)
const denied = createMemo(() => {
const message = error()
if (!message) return false
return (
message.includes("QuestionRejectedError") ||
message.includes("rejected permission") ||
message.includes("specified a rule") ||
message.includes("user dismissed")
)
})
const fg = createMemo(() => {
if (error()) return theme.error
if (complete()) return theme.textMuted
return theme.text
})
const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined))
return (
<box marginTop={1} paddingLeft={3} flexShrink={0}>
<Switch>
<Match when={props.spinner}>
<Spinner color={theme.text}>{props.children}</Spinner>
</Match>
<Match when={true}>
<text paddingLeft={3} fg={props.complete ? theme.textMuted : theme.text}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
{props.icon} {props.children}
</Show>
</text>
</Match>
</Switch>
<Show when={error() && !denied()}>
<text fg={theme.error}>{error()}</text>
</Show>
<box
marginTop={margin()}
paddingLeft={3}
flexShrink={0}
flexDirection="row"
gap={1}
backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
onMouseOver={() => error() && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (!error()) return
if (renderer.getSelection()?.getSelectedText()) return
setShowError((prev) => !prev)
}}
renderBefore={function () {
const el = this as BoxRenderable
const parent = el.parent
if (!parent) return
const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1]
if (!previous) {
setMargin(0)
return
}
if (previous.id.startsWith("text")) setMargin(1)
}}
>
<box flexShrink={0}>
<Switch>
<Match when={props.spinner}>
<Spinner color={theme.text} />
</Match>
<Match when={complete()}>
<text fg={fg()} attributes={attributes()}>
{props.icon}
</text>
</Match>
<Match when={true}>
<text fg={fg()} attributes={attributes()}>
~
</text>
</Match>
</Switch>
</box>
<box flexGrow={1}>
<box>
<Switch>
<Match when={complete()}>
<text fg={fg()} attributes={attributes()}>
{props.children}
</text>
</Match>
<Match when={true}>
<text fg={fg()} attributes={attributes()}>
{props.pending}
</text>
</Match>
</Switch>
</box>
<Show when={showError() && error()}>
<box>
<text fg={theme.error}>{error()}</text>
</box>
</Show>
</box>
</box>
)
}
@@ -1,5 +1,5 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createSignal } from "solid-js"
import { createEffect, createSignal, onMount } from "solid-js"
import { Logo } from "../component/logo"
import { useProject } from "../context/project"
import { useSync } from "../context/sync"
@@ -9,6 +9,7 @@ import { useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { useEditorContext } from "@tui/context/editor"
let once = false
const placeholder = {
@@ -24,8 +25,13 @@ export function Home() {
const [ref, setRef] = createSignal<PromptRef | undefined>()
const args = useArgs()
const local = useLocal()
const editor = useEditorContext()
let sent = false
onMount(() => {
editor.clearSelection()
})
const bind = (r: PromptRef | undefined) => {
setRef(r)
promptRef.set(r)
@@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { getScrollAcceleration } from "../../util/scroll"
import { WorkspaceLabel } from "../../component/workspace-label"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
@@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const workspaceStatus = () => {
const workspace = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "error"
return project.workspace.status(workspaceID) ?? "error"
}
const workspaceLabel = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "unknown"
const info = project.workspace.get(workspaceID)
if (!info) return "unknown"
return `${info.type}: ${info.name}`
if (!workspaceID) return
return project.workspace.get(workspaceID)
}
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
@@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</Show>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
{workspaceLabel()}
<Show
when={workspace()}
fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
>
{(item) => (
<WorkspaceLabel
type={item().type}
name={item().name}
status={project.workspace.status(item().id) ?? "error"}
icon
/>
)}
</Show>
</text>
</Show>
<Show when={session()!.share?.url}>
@@ -23,6 +23,7 @@ export interface DialogSelectProps<T> {
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean
renderFilter?: boolean
keybind?: {
keybind?: Keybind.Info
title: string
@@ -81,7 +82,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let input: InputRenderable
const filtered = createMemo(() => {
if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
const needle = store.filter.toLowerCase()
const options = pipe(
props.options,
@@ -250,30 +251,32 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
esc
</text>
</box>
<box paddingTop={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
setTimeout(() => {
if (!input) return
if (input.isDestroyed) return
input.focus()
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/>
</box>
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
setTimeout(() => {
if (!input) return
if (input.isDestroyed) return
input.focus()
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/>
</box>
</Show>
</box>
<Show
when={grouped().length > 0}
+2 -2
View File
@@ -41,7 +41,7 @@ export const WebCommand = effectCmd({
if (!Flag.KILO_SERVER_PASSWORD) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "! KILO_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
UI.empty()
UI.println(UI.logo(" "))
@@ -73,7 +73,7 @@ export const WebCommand = effectCmd({
}
// Open localhost in browser
open(localhostUrl.toString()).catch(() => {})
open(localhostUrl).catch(() => {})
} else {
const displayUrl = server.url.toString()
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
+4 -4
View File
@@ -1,6 +1,6 @@
import type { Argv, InferredOptionTypes } from "yargs"
import { Config } from "@/config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
const options = {
port: {
@@ -36,10 +36,10 @@ export type NetworkOptions = InferredOptionTypes<typeof options>
export function withNetworkOptions<T>(yargs: Argv<T>) {
return yargs.options(options)
}
export async function resolveNetworkOptions(args: NetworkOptions) {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
export const resolveNetworkOptions = Effect.fn("Cli.resolveNetworkOptions")(function* (args: NetworkOptions) {
const config = yield* Config.Service.use((cfg) => cfg.getGlobal())
return resolveNetworkOptionsNoConfig(args, config)
}
})
export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Config.Info) {
const portExplicitlySet = process.argv.includes("--port")
+69 -25
View File
@@ -96,6 +96,36 @@ export type Warning = z.infer<typeof Warning>
const { caught: caughtWarning } = KilocodeConfig
// kilocode_change end
async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) {
if (!isRecord(input.value) || typeof input.value.url !== "string") return
const url = await ConfigVariable.substitute({
text: input.value.url,
type: "virtual",
dir: input.dir,
source: input.source,
})
const headers = isRecord(input.value.headers)
? Object.fromEntries(
await Promise.all(
Object.entries(input.value.headers)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(async ([key, value]) => [
key,
await ConfigVariable.substitute({
text: value,
type: "virtual",
dir: input.dir,
source: input.source,
}),
]),
),
)
: undefined
return { url, headers }
}
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
@@ -634,42 +664,56 @@ 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`
process.env[value.key] = value.token
log.debug("fetching remote config", { url: source })
const source = `${url}/.well-known/opencode` // kilocode_change
// kilocode_change start - warn instead of fail on wellknown errors
const next = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(source)
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (await response.json()) as { config?: Record<string, unknown> }
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json"
return remoteConfig
},
catch: (err) => err,
}).pipe(
Effect.flatMap((remoteConfig) =>
loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
yield* Effect.gen(function* () {
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (yield* Effect.promise(() => response.json())) as {
config?: Record<string, unknown>
remote_config?: unknown
}
const remote = yield* Effect.promise(() =>
substituteWellKnownRemoteConfig({
value: wellknown.remote_config,
dir: url,
source: `${url}/.well-known/opencode`,
}),
),
Effect.tap(() => Effect.sync(() => log.debug("loaded remote config from well-known", { url }))),
)
const fetchedConfig = remote
? ((yield* Effect.promise(async () => {
log.debug("fetching remote config", { url: remote.url })
const response = await fetch(remote.url, { headers: remote.headers })
if (!response.ok)
throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`)
const data = await response.json()
return isRecord(data) && isRecord(data.config) ? data.config : data
})) as Record<string, unknown>)
: {}
const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info)
if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" // kilocode_change
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
})
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}).pipe(
Effect.catch((err: unknown) => {
caughtWarning(warnings, source, err)
log.warn("skipped remote config due to error", { url, err })
return Effect.succeed({} as Info)
return Effect.void
}),
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, source, err)
log.warn("skipped remote config due to error", { url, err })
return Effect.succeed({} as Info)
return Effect.void
}),
)
yield* merge(source, next, "global")
// kilocode_change end
}
}
+266 -132
View File
@@ -1,10 +1,11 @@
import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect"
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Database } from "@/storage/db"
import { asc } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { inArray } from "drizzle-orm"
import { Project } from "@/project/project"
import { Instance } from "@/project/instance"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth"
@@ -17,17 +18,22 @@ import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/core/util/slug"
import { WorkspaceTable } from "./workspace.sql"
import { getAdapter } from "./adapters"
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { Session } from "@/session/session"
import { SessionPrompt } from "@/session/prompt"
import { SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema"
import { NotFoundError } from "@/storage/storage"
import { errorData } from "@/util/error"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { EffectBridge } from "@/effect/bridge"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { withStatics } from "@/util/schema"
import { zod as effectZod, zodObject } from "@/util/effect-zod"
import { Vcs } from "@/project/vcs"
import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap"
export const Info = WorkspaceInfoSchema
export type Info = WorkspaceInfo
@@ -38,13 +44,6 @@ export const ConnectionStatus = Schema.Struct({
})
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
const Restore = Schema.Struct({
workspaceID: WorkspaceID,
sessionID: SessionID,
total: NonNegativeInt,
step: NonNegativeInt,
})
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
@@ -58,7 +57,6 @@ export const Event = {
message: Schema.String,
}),
),
Restore: BusEvent.define("workspace.restore", Restore),
Status: BusEvent.define("workspace.status", ConnectionStatus),
}
@@ -84,15 +82,16 @@ export const CreateInput = Schema.Struct({
type: Info.fields.type,
branch: Info.fields.branch,
projectID: ProjectID,
extra: Info.fields.extra,
extra: Schema.optional(Info.fields.extra),
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
export const SessionRestoreInput = Schema.Struct({
workspaceID: WorkspaceID,
export const SessionWarpInput = Schema.Struct({
workspaceID: Schema.NullOr(WorkspaceID),
sessionID: SessionID,
copyChanges: Schema.optional(Schema.Boolean),
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type SessionRestoreInput = Schema.Schema.Type<typeof SessionRestoreInput>
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
message: Schema.String,
@@ -116,8 +115,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass<SessionE
},
) {}
export class SessionRestoreHttpError extends Schema.TaggedErrorClass<SessionRestoreHttpError>()(
"WorkspaceSessionRestoreHttpError",
export class SessionWarpHttpError extends Schema.TaggedErrorClass<SessionWarpHttpError>()(
"WorkspaceSessionWarpHttpError",
{
message: Schema.String,
workspaceID: WorkspaceID,
@@ -138,17 +137,18 @@ export class SyncAbortedError extends Schema.TaggedErrorClass<SyncAbortedError>(
}) {}
type CreateError = Auth.AuthError
type SessionRestoreError =
type SessionWarpError =
| WorkspaceNotFoundError
| SessionEventsNotFoundError
| SessionRestoreHttpError
| SessionWarpHttpError
| Vcs.PatchApplyError
| HttpClientError.HttpClientError
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError>
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
@@ -169,8 +169,10 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const auth = yield* Auth.Service
const session = yield* Session.Service
const prompt = yield* SessionPrompt.Service
const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service
const vcs = yield* Vcs.Service
const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
@@ -259,6 +261,66 @@ export const layer = Layer.effect(
)
})
const runInWorkspace = <A, E, R>(input: {
workspaceID?: WorkspaceID
local: () => Effect.Effect<A, E, R>
remote: (input: {
workspace: Info
target: Extract<Target, { type: "remote" }>
}) => HttpClientRequest.HttpClientRequest
fallback: A
response?: "json" | "text"
}) =>
Effect.gen(function* () {
if (!input.workspaceID) return yield* input.local()
const workspace = yield* get(input.workspaceID)
if (!workspace) return input.fallback
const adapter = getAdapter(workspace.projectID, workspace.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace))
if (target.type === "local") {
const store = yield* InstanceStore.Service
return yield* store.provide({ directory: target.directory }, input.local())
}
const response = yield* http.execute(input.remote({ workspace, target })).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.warn("workspace target request failed", {
workspaceID: workspace.id,
error: errorData(error),
})
}),
),
)
if (!response) return input.fallback
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
log.warn("workspace target request failed", {
workspaceID: workspace.id,
status: response.status,
body,
})
return input.fallback
}
const body = input.response === "text" ? response.text : response.json
return yield* body.pipe(
Effect.map((result) => result as A),
Effect.catch((error) =>
Effect.sync(() => {
log.warn("workspace target response decode failed", {
workspaceID: workspace.id,
error: errorData(error),
})
return input.fallback
}),
),
)
})
const syncHistory = Effect.fn("Workspace.syncHistory")(function* (
space: Info,
url: URL | string,
@@ -461,7 +523,7 @@ export const layer = Layer.effect(
const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type)
const config = yield* EffectBridge.fromPromise(() =>
adapter.configure({ ...input, id, name: Slug.create(), directory: null }),
adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }),
)
const info: Info = {
@@ -518,29 +580,123 @@ export const layer = Layer.effect(
return info
})
const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) {
const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) {
return yield* Effect.gen(function* () {
log.info("session restore requested", {
log.info("session warp requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
const space = yield* get(input.workspaceID)
const current = yield* db((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get(),
)
if (current?.workspaceID) {
const previous = yield* get(current.workspaceID)
if (previous) {
const adapter = getAdapter(previous.projectID, previous.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(previous))
if (target.type === "remote") {
yield* syncHistory(previous, target.url, target.headers).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.warn("session warp final source sync failed", {
workspaceID: previous.id,
sessionID: input.sessionID,
error: errorData(error),
})
}),
),
)
} else {
yield* prompt.cancel(input.sessionID)
}
// "claim" this session so any future events coming from
// the old workspace are ignored
SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id)
}
}
const sourcePatch =
input.copyChanges && current?.workspaceID
? yield* runInWorkspace({
workspaceID: current?.workspaceID ?? undefined,
local: () => vcs.diffRaw(),
remote: ({ target }) =>
HttpClientRequest.get(route(target.url, "/vcs/diff/raw"), {
headers: new Headers(target.headers),
}),
fallback: "",
response: "text",
}).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))))
: ""
if (sourcePatch) {
// Attempt to apply the file changes to the new workspace.
// We intentionally do first so if it fails we don't warp
// the session.
yield* runInWorkspace({
workspaceID: input.workspaceID ?? undefined,
local: () => vcs.apply({ patch: sourcePatch }),
remote: ({ target }) =>
HttpClientRequest.post(route(target.url, "/vcs/apply"), {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({ patch: sourcePatch }),
}),
fallback: { applied: false },
}).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))))
}
if (input.workspaceID === null) {
yield* Effect.sync(() =>
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: null,
},
}),
)
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
target: "local",
})
return
}
const workspaceID = input.workspaceID
const space = yield* get(workspaceID)
if (!space)
return yield* new WorkspaceNotFoundError({
message: `Workspace not found: ${input.workspaceID}`,
workspaceID: input.workspaceID,
message: `Workspace not found: ${workspaceID}`,
workspaceID,
})
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
if (target.type === "local") {
yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
})
log.info("session warp complete", {
workspaceID: input.workspaceID,
},
})
sessionID: input.sessionID,
target: target.directory,
})
return
}
const rows = yield* db((db) =>
db
@@ -562,130 +718,95 @@ export const layer = Layer.effect(
sessionID: input.sessionID,
})
const size = 10
// TODO: look into using effect APIs to process this in chunks
const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) =>
rows.slice(i * size, (i + 1) * size),
)
const total = sets.length
const batches = Iterable.chunksOf(rows, 10)
const total = Iterable.size(batches)
log.info("session restore prepared", {
log.info("session warp prepared", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
workspaceType: space.type,
directory: space.directory,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
target: String(route(target.url, "/sync/replay")),
events: rows.length,
batches: total,
first: rows[0]?.seq,
last: rows.at(-1)?.seq,
})
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: 0,
},
},
}),
)
for (const [i, events] of sets.entries()) {
log.info("session restore batch starting", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
})
if (target.type === "local") {
yield* sync.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
})
} else {
const url = route(target.url, "/sync/replay")
const res = yield* http.execute(
HttpClientRequest.post(url, {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({
directory: space.directory ?? "",
events,
yield* Effect.forEach(
batches,
(events, i) =>
Effect.gen(function* () {
const response = yield* http.execute(
HttpClientRequest.post(route(target.url, "/sync/replay"), {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({
directory: space.directory ?? "",
events,
}),
}),
}),
)
)
if (res.status < 200 || res.status >= 300) {
const body = yield* res.text
log.error("session restore batch failed", {
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text
log.error("session warp batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: response.status,
body,
})
return yield* new SessionWarpHttpError({
message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
}
log.info("session warp batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
body,
status: response.status,
})
return yield* new SessionRestoreHttpError({
message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: res.status,
body,
})
}
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
})
}
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: i + 1,
},
},
}),
)
{ discard: true },
)
const response = yield* http.execute(
HttpClientRequest.post(route(target.url, "/sync/steal"), {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }),
}),
)
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text
log.error("session warp steal failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
return yield* new SessionWarpHttpError({
message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
}
log.info("session restore complete", {
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
return { total }
}).pipe(
Effect.tapError((err) =>
Effect.sync(() =>
log.error("session restore failed", {
log.error("session warp failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
@@ -715,9 +836,19 @@ export const layer = Layer.effect(
const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) {
const sessions = yield* db((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
db
.select({ id: SessionTable.id, parentID: SessionTable.parent_id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, id))
.all(),
)
const sessionIDs = new Set(sessions.map((sessionInfo) => sessionInfo.id))
yield* Effect.forEach(
sessions.filter((sessionInfo) => !sessionInfo.parentID || !sessionIDs.has(sessionInfo.parentID)),
(sessionInfo) =>
session.remove(sessionInfo.id).pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.void)),
{ discard: true },
)
yield* Effect.forEach(sessions, (sessionInfo) => session.remove(sessionInfo.id), { discard: true })
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
@@ -814,7 +945,7 @@ export const layer = Layer.effect(
return Service.of({
create,
sessionRestore,
sessionWarp,
list,
get,
remove,
@@ -830,6 +961,9 @@ export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
+3
View File
@@ -91,6 +91,9 @@ export const layer = Layer.effect(
cwd: dir,
env: item.environment,
extendEnv: true,
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}),
)
.pipe(
+15 -2
View File
@@ -69,6 +69,7 @@ export interface Options {
readonly cwd: string
readonly env?: Record<string, string>
readonly maxOutputBytes?: number
readonly stdin?: ChildProcess.CommandInput
}
export interface Interface {
@@ -86,6 +87,7 @@ export interface Interface {
readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
readonly applyPatch: (cwd: string, patch: string) => Effect.Effect<Result>
}
const kind = (code: string): Kind => {
@@ -102,6 +104,8 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const encoder = new TextEncoder()
const stdin = (text: string) => Stream.make(encoder.encode(text))
const run = Effect.fn("Git.run")(
function* (args: string[], opts: Options) {
@@ -109,7 +113,7 @@ export const layer = Layer.effect(
cwd: opts.cwd,
env: opts.env,
extendEnv: true,
stdin: "ignore",
stdin: opts.stdin ?? "ignore",
stdout: "pipe",
stderr: "pipe",
})
@@ -317,9 +321,13 @@ export const layer = Layer.effect(
cwd,
maxOutputBytes: 4096,
})
if (result.truncated) return
const parts = result.text().split("\t")
const text = result.text()
const parts = text.split("\t")
if (parts.length < 2) return
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10)
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10)
return {
@@ -329,6 +337,10 @@ export const layer = Layer.effect(
} satisfies Stat
})
const applyPatch = Effect.fn("Git.applyPatch")(function* (cwd: string, patch: string) {
return yield* run(["apply", "-"], { cwd, stdin: stdin(patch) })
})
return Service.of({
run,
branch,
@@ -344,6 +356,7 @@ export const layer = Layer.effect(
patchAll,
patchUntracked,
statUntracked,
applyPatch,
})
}),
)
+1
View File
@@ -14,6 +14,7 @@ const prefixes = {
tool: "tool",
workspace: "wrk",
entry: "ent",
account: "act",
} as const
export function schema(prefix: keyof typeof prefixes) {
+87 -2
View File
@@ -6,7 +6,7 @@ import { InstanceState } from "@/effect/instance-state"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import * as Log from "@opencode-ai/core/util/log"
import { zod } from "@/util/effect-zod"
import { zod, zodObject } from "@/util/effect-zod"
import { makeRuntime } from "@/effect/run-service" // kilocode_change
import { NonNegativeInt, withStatics } from "@/util/schema"
@@ -86,7 +86,9 @@ const fileFromPatchChunk = (chunk: string) => {
}
const splitGitPatch = (patch: Git.Patch) => {
const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index)
const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
match[0].startsWith("\n") ? match.index + 1 : match.index,
)
const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length))
if (!patch.truncated) return chunks
return chunks.slice(0, -1)
@@ -238,11 +240,39 @@ export const FileDiff = Schema.Struct({
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type FileDiff = Schema.Schema.Type<typeof FileDiff>
export const FileStatus = Schema.Struct({
file: Schema.String,
additions: NonNegativeInt,
deletions: NonNegativeInt,
status: Schema.Literals(["added", "deleted", "modified"]),
})
.annotate({ identifier: "VcsFileStatus" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type FileStatus = Schema.Schema.Type<typeof FileStatus>
export const ApplyInput = Schema.Struct({
patch: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) })))
export type ApplyInput = Schema.Schema.Type<typeof ApplyInput>
export const ApplyResult = Schema.Struct({
applied: Schema.Boolean,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type ApplyResult = Schema.Schema.Type<typeof ApplyResult>
export class PatchApplyError extends Schema.TaggedErrorClass<PatchApplyError>()("VcsPatchApplyError", {
message: Schema.String,
reason: Schema.Literals(["non-git", "not-clean"]),
}) {}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => Effect.Effect<string | undefined>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
readonly diffRaw: () => Effect.Effect<string>
readonly apply: (input: ApplyInput) => Effect.Effect<ApplyResult, PatchApplyError>
}
interface State {
@@ -303,6 +333,31 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
return yield* InstanceState.use(state, (x) => x.root?.name)
}),
status: Effect.fn("Vcs.status")(function* () {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return []
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
const [list, stats] = yield* Effect.all(
[git.status(ctx.directory), ref ? git.stats(ctx.directory, ref) : Effect.succeed([])],
{ concurrency: 2 },
)
const map = nums(stats)
return yield* Effect.forEach(
list.toSorted((a, b) => a.file.localeCompare(b.file)),
(item) =>
Effect.gen(function* () {
const stat =
map.get(item.file) ??
(item.status === "added" ? yield* git.statUntracked(ctx.worktree, item.file) : undefined)
return {
file: item.file,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
status: item.status,
} satisfies FileStatus
}),
)
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
const value = yield* InstanceState.get(state)
const ctx = yield* InstanceState.context
@@ -317,6 +372,36 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
if (!ref) return []
return yield* diffAgainstRef(git, ctx.directory, ref)
}),
diffRaw: Effect.fn("Vcs.diffRaw")(function* () {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return ""
const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], {
concurrency: 2,
})
const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD")).text : ""
const untracked = yield* Effect.forEach(
status.filter((item) => item.code === "??"),
(item) => git.patchUntracked(ctx.directory, item.file).pipe(Effect.map((patch) => patch.text)),
)
return [tracked, ...untracked].filter(Boolean).join("\n")
}),
apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") {
return yield* new PatchApplyError({
message: "Patch can't be applied because the project is not git-based",
reason: "non-git",
})
}
const applied = yield* git.applyPatch(ctx.directory, input.patch)
if (applied.exitCode !== 0) {
return yield* new PatchApplyError({
message: "Patch can't be applied",
reason: "not-clean",
})
}
return { applied: true }
}),
})
}),
)
+1
View File
@@ -156,6 +156,7 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined
isRetryable: false,
responseBody,
}
case "server_is_overloaded":
case "server_error":
return {
type: "api_error",
+131 -22
View File
@@ -1,4 +1,4 @@
import type { ModelMessage } from "ai"
import type { ModelMessage, ToolResultPart } from "ai"
import { mergeDeep, unique } from "remeda"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type { JSONSchema } from "zod/v4/core"
@@ -21,6 +21,10 @@ function mimeToModality(mime: string): Modality | undefined {
export const OUTPUT_TOKEN_MAX = Flag.KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
export function sanitizeSurrogates(content: string) {
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
}
// Maps npm package to the key the AI SDK expects for providerOptions
function sdkKey(npm: string): string | undefined {
switch (npm) {
@@ -45,15 +49,85 @@ function sdkKey(npm: string): string | undefined {
return "openrouter"
case "@kilocode/kilo-gateway": // kilocode_change
return "openrouter"
case "ai-gateway-provider":
// ai-gateway-provider/unified wraps createOpenAICompatible({ name: "Unified" }),
// and @ai-sdk/openai-compatible parses compatibleOptions from one of
// "openai-compatible" / "openaiCompatible" / "Unified" / "unified". The
// "openai-compatible" key emits a deprecation warning at runtime, so we
// pick the camelCase form the SDK now treats as canonical.
return "openaiCompatible"
}
return undefined
}
// TODO: fix this stupid inefficient dogshit function
function normalizeMessages(
msgs: ModelMessage[],
model: Provider.Model,
_options: Record<string, unknown>,
): ModelMessage[] {
const sanitizeToolResultOutput = (content: ToolResultPart) => {
if (content.output.type === "text" || content.output.type === "error-text") {
content.output.value = sanitizeSurrogates(content.output.value)
}
if (content.output.type === "content") {
content.output.value = content.output.value.map((item) => {
if (item.type === "text") {
item.text = sanitizeSurrogates(item.text)
}
return item
})
}
return content
}
msgs = msgs.map((msg) => {
switch (msg.role) {
case "tool":
if (!Array.isArray(msg.content)) return msg
msg.content = msg.content.map((content) => {
if (content.type === "tool-result") {
return sanitizeToolResultOutput(content)
}
return content
})
return msg
case "system":
msg.content = sanitizeSurrogates(msg.content)
return msg
case "user":
if (typeof msg.content === "string") {
msg.content = sanitizeSurrogates(msg.content)
} else {
msg.content = msg.content.map((content) => {
if (content.type === "text") {
content.text = sanitizeSurrogates(content.text)
}
return content
})
}
return msg
case "assistant":
if (typeof msg.content === "string") {
msg.content = sanitizeSurrogates(msg.content)
} else {
msg.content = msg.content.map((content) => {
if (content.type === "text" || content.type === "reasoning") {
content.text = sanitizeSurrogates(content.text)
}
if (content.type === "tool-result") {
return sanitizeToolResultOutput(content)
}
return content
})
}
return msg
}
})
// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic") {
@@ -437,6 +511,36 @@ export function topK(model: Provider.Model) {
const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]
const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
// OpenAI rolled out the `none` reasoning_effort tier on this date (Responses API).
// Models released before it 400 on `reasoning_effort: "none"`, so we only expose
// it as a variant for models new enough to accept it.
const OPENAI_NONE_EFFORT_RELEASE_DATE = "2025-11-13"
// OpenAI rolled out the `xhigh` reasoning_effort tier on this date. Same reasoning.
const OPENAI_XHIGH_EFFORT_RELEASE_DATE = "2025-12-04"
// Matches members of the gpt-5 family across the id formats we encounter:
// "gpt-5", "gpt-5-nano", "gpt-5.4", "openai/gpt-5.4-codex".
// Anchored to start-of-string or "/" so it doesn't false-match "gpt-50" or "gpt-5o".
const GPT5_FAMILY_RE = /(?:^|\/)gpt-5(?:[.-]|$)/
// Computes the reasoning_effort tiers an OpenAI (or OpenAI-compatible upstream
// routed through it, e.g. cf-ai-gateway) model exposes. Returns null for models
// with no tunable effort knob (gpt-5-pro). Effort order: weakest to strongest.
function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | null {
const id = apiId.toLowerCase()
if (id === "gpt-5-pro" || id === "openai/gpt-5-pro") return null
if (id.includes("codex")) {
if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
return [...WIDELY_SUPPORTED_EFFORTS]
}
const efforts = [...WIDELY_SUPPORTED_EFFORTS]
if (GPT5_FAMILY_RE.test(id)) efforts.unshift("minimal")
if (releaseDate >= OPENAI_NONE_EFFORT_RELEASE_DATE) efforts.unshift("none")
if (releaseDate >= OPENAI_XHIGH_EFFORT_RELEASE_DATE) efforts.push("xhigh")
return efforts
}
function anthropicAdaptiveEfforts(apiId: string): string[] | null {
if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) {
return ["low", "medium", "high", "xhigh", "max"]
@@ -514,6 +618,21 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
return {}
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))
case "ai-gateway-provider": {
// Cloudflare AI Gateway routes every upstream through its OpenAI-compatible
// /v1/compat endpoint, so the body is always OAI-shaped. The gateway
// translates `reasoning_effort` to the upstream provider's native control
// (e.g. Anthropic thinking budgets) when needed. Variants therefore stay
// OAI-style for all upstreams, with an extended effort set for OpenAI
// models that support it.
if (model.api.id.startsWith("openai/")) {
const efforts = openaiReasoningEfforts(model.api.id, model.release_date)
if (!efforts) return {}
return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }]))
}
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
}
case "@ai-sdk/gateway":
if (model.id.includes("anthropic")) {
if (adaptiveEfforts) {
@@ -638,28 +757,12 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
},
]),
)
case "@ai-sdk/openai":
case "@ai-sdk/openai": {
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai
if (id === "gpt-5-pro") return {}
const openaiEfforts = iife(() => {
if (id.includes("codex")) {
if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
return WIDELY_SUPPORTED_EFFORTS
}
const arr = [...WIDELY_SUPPORTED_EFFORTS]
if (id.includes("gpt-5-") || id === "gpt-5") {
arr.unshift("minimal")
}
if (model.release_date >= "2025-11-13") {
arr.unshift("none")
}
if (model.release_date >= "2025-12-04") {
arr.push("xhigh")
}
return arr
})
const efforts = openaiReasoningEfforts(model.api.id, model.release_date)
if (!efforts) return {}
return Object.fromEntries(
openaiEfforts.map((effort) => [
efforts.map((effort) => [
effort,
{
reasoningEffort: effort,
@@ -668,6 +771,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
},
]),
)
}
case "@ai-sdk/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
@@ -804,7 +908,12 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
// https://docs.mistral.ai/capabilities/reasoning/adjustable
if (!model.capabilities.reasoning) return {}
// Only Mistral Small 4 and Medium 3.5 support reasoning
const MISTRAL_REASONING_IDS = ["mistral-small-2603", "mistral-small-latest", "mistral-medium-3.5"]
const MISTRAL_REASONING_IDS = [
"mistral-small-2603",
"mistral-small-latest",
"mistral-medium-3.5",
"mistral-medium-2604",
]
const mistralId = model.api.id.toLowerCase()
if (!MISTRAL_REASONING_IDS.some((id) => mistralId.includes(id))) return {}
return {
+6 -4
View File
@@ -1,3 +1,4 @@
import { EventEmitter } from "node:events"
import { createAdaptorServer, type ServerType } from "@hono/node-server"
import { createNodeWebSocket } from "@hono/node-ws"
import type { Hono } from "hono"
@@ -7,6 +8,7 @@ async function listen(app: FetchApp, opts: Opts, inject?: (server: ServerType) =
const start = (port: number) =>
new Promise<ServerType>((resolve, reject) => {
const server = createAdaptorServer({ fetch: app.fetch })
const events = server as EventEmitter
inject?.(server)
const fail = (err: Error) => {
cleanup()
@@ -17,11 +19,11 @@ async function listen(app: FetchApp, opts: Opts, inject?: (server: ServerType) =
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
events.off("error", fail)
events.off("listening", ready)
}
server.once("error", fail)
server.once("listening", ready)
events.once("error", fail)
events.once("listening", ready)
server.listen(port, opts.hostname)
})
@@ -8,12 +8,9 @@ import { AppRuntime } from "@/effect/app-runtime"
import { WorkspaceAdapterEntry } from "@/control-plane/types"
import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance"
import { Vcs } from "@/project/vcs"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import * as Log from "@opencode-ai/core/util/log"
import { errorData } from "@/util/error"
const log = Log.create({ service: "server.workspace" })
export const WorkspaceRoutes = lazy(() =>
new Hono()
@@ -151,60 +148,64 @@ export const WorkspaceRoutes = lazy(() =>
},
)
.post(
"/:id/session-restore",
"/warp",
describeRoute({
summary: "Restore session into workspace",
description: "Replay a session's sync events into the target workspace in batches.",
operationId: "experimental.workspace.sessionRestore",
summary: "Warp session into workspace",
description: "Move a session's sync history into the target workspace, or detach it to the local project.",
operationId: "experimental.workspace.warp",
responses: {
200: {
description: "Session replay started",
content: {
"application/json": {
schema: resolver(
z.object({
total: z.number().int().min(0),
}),
),
},
},
204: {
description: "Session warped",
},
...errors(400),
},
}),
validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })),
validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })),
validator(
"json",
z.object({
id: zodObject(Workspace.Info).shape.id.nullable(),
sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
copyChanges: z.boolean().optional(),
}),
),
async (c) => {
const { id } = c.req.valid("param")
const body = c.req.valid("json") as Omit<Workspace.SessionRestoreInput, "workspaceID">
log.info("session restore route requested", {
workspaceID: id,
sessionID: body.sessionID,
directory: Instance.directory,
})
try {
const result = await AppRuntime.runPromise(
Workspace.Service.use((svc) =>
svc.sessionRestore({
workspaceID: id,
...body,
}),
),
)
log.info("session restore route complete", {
workspaceID: id,
sessionID: body.sessionID,
total: result.total,
})
return c.json(result)
} catch (err) {
log.error("session restore route failed", {
workspaceID: id,
sessionID: body.sessionID,
error: errorData(err),
})
throw err
}
const body = c.req.valid("json")
return AppRuntime.runPromise(
Workspace.Service.use((workspace) =>
workspace.sessionWarp({
workspaceID: body.id,
sessionID: body.sessionID,
copyChanges: body.copyChanges,
}),
).pipe(
Effect.match({
onFailure: (error) => {
if (error instanceof Vcs.PatchApplyError) {
return c.json(
{
name: "VcsApplyError",
data: {
message: error.message,
reason: error.reason,
},
},
400,
)
}
return c.json(
{
name: "WorkspaceWarpError",
data: {
message: error.message,
},
},
400,
)
},
onSuccess: () => c.body(null, 204),
}),
),
)
},
),
)
@@ -32,4 +32,6 @@ Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally re
Use `Effect.provideService(...)` in middleware only for request-derived context, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. Do not use it to smuggle stable services through request effects when they can be yielded at layer construction.
Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary.
When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled.
@@ -0,0 +1,18 @@
import { Schema } from "effect"
export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")(
{
name: Schema.Literal("NotFoundError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 404 },
) {}
export function notFound(message: string) {
return new ApiNotFoundError({
name: "NotFoundError",
data: { message },
})
}
@@ -5,7 +5,7 @@ import { LSP } from "@/lsp/lsp"
import { Vcs } from "@/project/vcs"
import { Skill } from "@/skill"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
@@ -23,11 +23,25 @@ export const VcsDiffQuery = Schema.Struct({
mode: Vcs.Mode,
})
export class ApiVcsApplyError extends Schema.ErrorClass<ApiVcsApplyError>("VcsApplyError")(
{
name: Schema.Literal("VcsApplyError"),
data: Schema.Struct({
message: Schema.String,
reason: Schema.Literals(["non-git", "not-clean"]),
}),
},
{ httpApiStatus: 400 },
) {}
export const InstancePaths = {
dispose: "/instance/dispose",
path: "/path",
vcs: "/vcs",
vcsStatus: "/vcs/status",
vcsDiff: "/vcs/diff",
vcsDiffRaw: "/vcs/diff/raw",
vcsApply: "/vcs/apply",
command: "/command",
agent: "/agent",
skill: "/skill",
@@ -68,6 +82,15 @@ export const InstanceApi = HttpApi.make("instance")
"Retrieve version control system (VCS) information for the current project, such as git branch.",
}),
),
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
}).annotateMerge(
OpenApi.annotations({
identifier: "vcs.status",
summary: "Get VCS status",
description: "Retrieve changed files in the current working tree without patches.",
}),
),
HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
query: VcsDiffQuery,
success: described(Schema.Array(Vcs.FileDiff), "VCS diff"),
@@ -78,6 +101,29 @@ export const InstanceApi = HttpApi.make("instance")
description: "Retrieve the current git diff for the working tree or against the default branch.",
}),
),
HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
success: described(
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
"Raw VCS diff",
),
}).annotateMerge(
OpenApi.annotations({
identifier: "vcs.diff.raw",
summary: "Get raw VCS diff",
description: "Retrieve a raw patch for current uncommitted changes.",
}),
),
HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
payload: Vcs.ApplyInput,
success: described(Vcs.ApplyResult, "VCS patch applied"),
error: ApiVcsApplyError,
}).annotateMerge(
OpenApi.annotations({
identifier: "vcs.apply",
summary: "Apply VCS patch",
description: "Apply a raw patch to the current working tree.",
}),
),
HttpApiEndpoint.get("command", InstancePaths.command, {
success: described(Schema.Array(Command.Info), "List of commands"),
}).annotateMerge(
@@ -6,6 +6,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
const root = "/pty"
@@ -64,7 +65,7 @@ export const PtyApi = HttpApi.make("pty")
HttpApiEndpoint.get("get", PtyPaths.get, {
params: { ptyID: PtyID },
success: described(Pty.Info, "Session info"),
error: HttpApiError.NotFound,
error: ApiNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.get",
@@ -76,7 +77,7 @@ export const PtyApi = HttpApi.make("pty")
params: { ptyID: PtyID },
payload: Pty.UpdateInput,
success: described(Pty.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.update",
@@ -87,7 +88,7 @@ export const PtyApi = HttpApi.make("pty")
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
params: { ptyID: PtyID },
success: described(Schema.Boolean, "Session removed"),
error: HttpApiError.NotFound,
error: ApiNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.remove",
@@ -98,7 +99,7 @@ export const PtyApi = HttpApi.make("pty")
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
params: { ptyID: PtyID },
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
error: [HttpApiError.Forbidden, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connectToken",
@@ -15,6 +15,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
const root = "/session"
@@ -130,7 +131,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
success: described(Session.Info, "Get session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.get",
@@ -175,7 +176,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: MessagesQuery,
success: described(Schema.Array(MessageV2.WithParts), "List of messages"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.messages",
@@ -186,7 +187,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
success: described(MessageV2.WithParts, "Message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.message",
@@ -208,7 +209,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
success: described(Schema.Boolean, "Successfully deleted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.delete",
@@ -220,7 +221,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: UpdatePayload,
success: described(Session.Info, "Successfully updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.update",
@@ -232,6 +233,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: ForkPayload,
success: described(Session.Info, "200"),
error: ApiNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
@@ -266,7 +268,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully shared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.share",
@@ -277,7 +279,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully unshared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unshare",
@@ -289,7 +291,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: SummarizePayload,
success: described(Schema.Boolean, "Summarized session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.summarize",
@@ -1,4 +1,5 @@
import { NonNegativeInt } from "@/util/schema"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
@@ -21,6 +22,9 @@ export const ReplayPayload = Schema.Struct({
export const ReplayResponse = Schema.Struct({
sessionID: Schema.String,
})
export const SessionPayload = Schema.Struct({
sessionID: SessionID,
})
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
export const HistoryEvent = Schema.Struct({
id: Schema.String,
@@ -33,6 +37,7 @@ export const HistoryEvent = Schema.Struct({
export const SyncPaths = {
start: `${root}/start`,
replay: `${root}/replay`,
steal: `${root}/steal`,
history: `${root}/history`,
} as const
@@ -60,6 +65,17 @@ export const SyncApi = HttpApi.make("sync")
description: "Validate and replay a complete sync event history.",
}),
),
HttpApiEndpoint.post("steal", SyncPaths.steal, {
payload: SessionPayload,
success: described(SessionPayload, "Session stolen into workspace"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.steal",
summary: "Steal session into workspace",
description: "Update a session to belong to the current workspace through the sync event system.",
}),
),
HttpApiEndpoint.post("history", SyncPaths.history, {
payload: HistoryPayload,
success: described(Schema.Array(HistoryEvent), "Sync events"),
@@ -4,6 +4,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
const root = "/tui"
@@ -155,7 +156,7 @@ export const TuiApi = HttpApi.make("tui")
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
payload: TuiEvent.SessionSelect.properties,
success: described(Schema.Boolean, "Session selected successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.selectSession",
@@ -1,29 +1,37 @@
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdapterEntry } from "@/control-plane/types"
import { NonNegativeInt } from "@/util/schema"
import { Schema, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { ApiVcsApplyError } from "./instance"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/experimental/workspace"
export const CreatePayload = Schema.Struct({
...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]),
extra: Schema.optional(Workspace.CreateInput.fields.extra),
})
export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
export const SessionRestoreResponse = Schema.Struct({
total: NonNegativeInt,
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
export const WarpPayload = Schema.Struct({
id: Schema.NullOr(Workspace.Info.fields.id),
sessionID: Workspace.SessionWarpInput.fields.sessionID,
copyChanges: Workspace.SessionWarpInput.fields.copyChanges,
})
export class ApiWorkspaceWarpError extends Schema.ErrorClass<ApiWorkspaceWarpError>("WorkspaceWarpError")(
{
name: Schema.Literal("WorkspaceWarpError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 400 },
) {}
export const WorkspacePaths = {
adapters: `${root}/adapter`,
list: root,
status: `${root}/status`,
remove: `${root}/:id`,
sessionRestore: `${root}/:id/session-restore`,
warp: `${root}/warp`,
} as const
export const WorkspaceApi = HttpApi.make("workspace")
@@ -79,16 +87,15 @@ export const WorkspaceApi = HttpApi.make("workspace")
description: "Remove an existing workspace.",
}),
),
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
params: { id: Workspace.Info.fields.id },
payload: SessionRestorePayload,
success: described(SessionRestoreResponse, "Session replay started"),
error: HttpApiError.BadRequest,
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
payload: WarpPayload,
success: described(HttpApiSchema.NoContent, "Session warped"),
error: [ApiWorkspaceWarpError, ApiVcsApplyError],
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.sessionRestore",
summary: "Restore session into workspace",
description: "Replay a session's sync events into the target workspace in batches.",
identifier: "experimental.workspace.warp",
summary: "Warp session into workspace",
description: "Move a session's sync history into the target workspace, or detach it to the local project.",
}),
),
)
@@ -9,6 +9,7 @@ import { Skill } from "@/skill"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { ApiVcsApplyError } from "../groups/instance"
import { markInstanceForDisposal } from "../lifecycle"
export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) =>
@@ -41,10 +42,33 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
return { branch, default_branch }
})
const getVcsStatus = Effect.fn("InstanceHttpApi.vcsStatus")(function* () {
return yield* vcs.status()
})
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
return yield* vcs.diff(ctx.query.mode)
})
const getVcsDiffRaw = Effect.fn("InstanceHttpApi.vcsDiffRaw")(function* () {
return yield* vcs.diffRaw()
})
const applyVcs = Effect.fn("InstanceHttpApi.vcsApply")(function* (ctx: { payload: Vcs.ApplyInput }) {
return yield* vcs.apply(ctx.payload).pipe(
Effect.mapError(
(error) =>
new ApiVcsApplyError({
name: "VcsApplyError",
data: {
message: error.message,
reason: error.reason,
},
}),
),
)
})
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
return yield* command.list()
})
@@ -69,7 +93,10 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
.handle("dispose", dispose)
.handle("path", getPath)
.handle("vcs", getVcs)
.handle("vcsStatus", getVcsStatus)
.handle("vcsDiff", getVcsDiff)
.handle("vcsDiffRaw", getVcsDiffRaw)
.handle("vcsApply", applyVcs)
.handle("command", getCommand)
.handle("agent", getAgent)
.handle("skill", getSkill)
@@ -15,6 +15,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import * as ApiError from "../errors"
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
import { WebSocketTracker } from "../websocket-tracker"
@@ -46,7 +47,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
const info = yield* pty.get(ctx.params.ptyID)
if (!info) return yield* new HttpApiError.NotFound({})
if (!info) return yield* ApiError.notFound("Session not found")
return info
})
@@ -58,7 +59,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
})
if (!info) return yield* new HttpApiError.NotFound({})
if (!info) return yield* ApiError.notFound("Session not found")
return info
})
@@ -71,7 +72,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
const request = yield* HttpServerRequest.HttpServerRequest
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
return yield* new HttpApiError.Forbidden({})
if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({})
if (!(yield* pty.get(ctx.params.ptyID))) return yield* ApiError.notFound("Session not found")
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
})
@@ -0,0 +1,9 @@
import type { NotFoundError as StorageNotFoundError } from "@/storage/storage"
import { Effect } from "effect"
import * as ApiError from "../errors"
type StorageNotFound = InstanceType<typeof StorageNotFoundError>
export function mapStorageNotFound<A, R>(self: Effect.Effect<A, StorageNotFound, R>) {
return self.pipe(Effect.mapError((error) => ApiError.notFound(error.data.message)))
}
@@ -38,14 +38,7 @@ import {
UpdatePayload,
ViewedPayload,
} from "../groups/session"
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
Effect.catchDefect((error) =>
NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error),
),
)
import * as SessionError from "./session-errors"
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
@@ -80,7 +73,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* mapNotFound(session.get(ctx.params.sessionID))
return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
})
const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
@@ -102,51 +95,49 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
query: typeof MessagesQuery.Type
}) {
return yield* mapNotFound(
Effect.gen(function* () {
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
if (ctx.query.before) {
const before = ctx.query.before
yield* Effect.try({
try: () => MessageV2.cursor.decode(before),
catch: () => new HttpApiError.BadRequest({}),
})
}
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
yield* session.get(ctx.params.sessionID)
return yield* session.messages({ sessionID: ctx.params.sessionID })
}
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
if (ctx.query.before) {
const before = ctx.query.before
yield* Effect.try({
try: () => MessageV2.cursor.decode(before),
catch: () => new HttpApiError.BadRequest({}),
})
}
yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
return yield* session.messages({ sessionID: ctx.params.sessionID })
}
yield* session.get(ctx.params.sessionID)
const page = MessageV2.page({
sessionID: ctx.params.sessionID,
limit: ctx.query.limit,
before: ctx.query.before,
})
if (!page.cursor) return page.items
const page = MessageV2.page({
sessionID: ctx.params.sessionID,
limit: ctx.query.limit,
before: ctx.query.before,
})
if (!page.cursor) return page.items
const request = yield* HttpServerRequest.HttpServerRequest
// toURL() honors the Host + x-forwarded-proto headers, so the Link
// header echoes the real origin instead of a hard-coded localhost.
const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost"))
url.searchParams.set("limit", ctx.query.limit.toString())
url.searchParams.set("before", page.cursor)
return HttpServerResponse.jsonUnsafe(page.items, {
headers: {
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
Link: `<${url.toString()}>; rel="next"`,
"X-Next-Cursor": page.cursor,
},
})
}),
)
const request = yield* HttpServerRequest.HttpServerRequest
// toURL() honors the Host + x-forwarded-proto headers, so the Link
// header echoes the real origin instead of a hard-coded localhost.
const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost"))
url.searchParams.set("limit", ctx.query.limit.toString())
url.searchParams.set("before", page.cursor)
return HttpServerResponse.jsonUnsafe(page.items, {
headers: {
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
Link: `<${url.toString()}>; rel="next"`,
"X-Next-Cursor": page.cursor,
},
})
})
const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
}) {
return yield* mapNotFound(
Effect.sync(() => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })),
return yield* SessionError.mapStorageNotFound(
Effect.try({
try: () => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
catch: (error) => error,
}).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error)))),
)
})
@@ -171,7 +162,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* session.remove(ctx.params.sessionID)
yield* SessionError.mapStorageNotFound(session.remove(ctx.params.sessionID))
return true
})
@@ -179,7 +170,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof UpdatePayload.Type
}) {
const current = yield* session.get(ctx.params.sessionID)
const current = yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
if (ctx.payload.title !== undefined) {
yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
}
@@ -192,14 +183,16 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
if (ctx.payload.time?.archived !== undefined) {
yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
}
return yield* session.get(ctx.params.sessionID)
return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
})
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ForkPayload.Type
}) {
return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID })
return yield* SessionError.mapStorageNotFound(
session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }),
)
})
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
@@ -223,19 +216,19 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* session.get(ctx.params.sessionID)
return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
})
const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* session.get(ctx.params.sessionID)
return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
})
const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof SummarizePayload.Type
}) {
yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
yield* revertSvc.cleanup(yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agentSvc.defaultAgent()
const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
@@ -1,5 +1,6 @@
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Session } from "@/session/session"
import { Database } from "@/storage/db"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
@@ -12,7 +13,7 @@ import { or } from "drizzle-orm"
import { Effect, Scope } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { HistoryPayload, ReplayPayload } from "../groups/sync"
import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "server.sync" })
@@ -56,6 +57,25 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
return { sessionID: source }
})
const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) {
const workspaceID = yield* InstanceState.workspaceID
if (!workspaceID) throw new Error("Cannot steal session without workspace context")
yield* sync.run(Session.Event.Updated, {
sessionID: ctx.payload.sessionID,
info: {
workspaceID,
},
})
log.info("sync session stolen", {
sessionID: ctx.payload.sessionID,
workspaceID,
})
return { sessionID: ctx.payload.sessionID }
})
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
const exclude = Object.entries(ctx.payload)
return Database.use((db) =>
@@ -72,6 +92,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
)
})
return handlers.handle("start", start).handle("replay", replay).handle("history", history)
return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history)
}),
)
@@ -1,13 +1,12 @@
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { SessionTable } from "@/session/session.sql"
import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
import { Session } from "@/session/session"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control"
import { InstanceHttpApi } from "../api"
import { CommandPayload, TuiPublishPayload } from "../groups/tui"
import * as SessionError from "./session-errors"
const commandAliases = {
session_new: "session.new",
@@ -28,6 +27,7 @@ const commandAliases = {
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const session = yield* Session.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) =>
bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type)
@@ -98,12 +98,7 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler
payload: typeof TuiEvent.SessionSelect.properties.Type
}) {
if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
const row = yield* Effect.sync(() =>
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(),
),
)
if (!row) return yield* new HttpApiError.NotFound({})
yield* SessionError.mapStorageNotFound(session.get(ctx.payload.sessionID))
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
return true
})
@@ -1,10 +1,12 @@
import { listAdapters } from "@/control-plane/adapters"
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Vcs } from "@/project/vcs"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
import { ApiVcsApplyError } from "../groups/instance"
import { ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace"
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
Effect.gen(function* () {
@@ -39,16 +41,32 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
return yield* workspace.remove(ctx.params.id)
})
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
params: { id: Workspace.Info["id"] }
payload: typeof SessionRestorePayload.Type
}) {
return yield* workspace
.sessionRestore({
workspaceID: ctx.params.id,
const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) {
yield* workspace
.sessionWarp({
workspaceID: ctx.payload.id,
sessionID: ctx.payload.sessionID,
copyChanges: ctx.payload.copyChanges,
})
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
.pipe(
Effect.mapError((error) => {
if (error instanceof Vcs.PatchApplyError) {
return new ApiVcsApplyError({
name: "VcsApplyError",
data: {
message: error.message,
reason: error.reason,
},
})
}
return new ApiWorkspaceWarpError({
name: "WorkspaceWarpError",
data: {
message: error.message,
},
})
}),
)
})
return handlers
@@ -57,6 +75,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
.handle("create", create)
.handle("status", status)
.handle("remove", remove)
.handle("sessionRestore", sessionRestore)
.handle("warp", warp)
}),
)
@@ -7,6 +7,7 @@ import { Session } from "@/session/session"
import { HttpApiProxy } from "./proxy"
import * as Fence from "@/server/shared/fence"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing"
import { NotFoundError } from "@/storage/storage"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Data, Effect, Layer } from "effect"
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -178,7 +179,10 @@ function routeHttpApiWorkspace<E>(
const request = yield* HttpServerRequest.HttpServerRequest
const sessionID = getWorkspaceRouteSessionID(requestURL(request))
const session = sessionID
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void))
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)),
Effect.catchDefect(() => Effect.succeed(undefined)),
)
: undefined
const plan = yield* planRequest(request, session?.workspaceID)
return yield* routeWorkspace(client, effect, plan)
@@ -149,6 +149,16 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] }
if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] }
}
if (path === "/experimental/workspace/warp" && method === "post") {
const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace(
"#/components/schemas/",
"",
)
const properties = ref
? spec.components?.schemas?.[ref]?.properties
: operation.requestBody.content?.["application/json"]?.schema?.properties
if (properties?.id) properties.id = { anyOf: [properties.id, { type: "null" }] }
}
}
for (const response of Object.values(operation.responses ?? {})) {
for (const content of Object.values(response.content ?? {})) {
@@ -27,7 +27,7 @@ import { ProviderRoutes } from "./provider"
import { EventRoutes } from "./event"
import { SyncRoutes } from "./sync"
import { InstanceMiddleware } from "./middleware"
import { jsonRequest } from "./trace"
import { jsonRequest, runRequest } from "./trace"
import { register as registerKiloRoutes } from "@/kilocode/server/instance" // kilocode_change
import { ExperimentalHttpApiServer } from "./httpapi/server"
import { EventPaths } from "./httpapi/event"
@@ -42,6 +42,7 @@ import { TuiPaths } from "./httpapi/groups/tui"
import { WorkspacePaths } from "./httpapi/groups/workspace"
import { register as registerKiloHttpApiRoutes } from "@/kilocode/server/httpapi/instance" // kilocode_change
import type { CorsOptions } from "@/server/cors"
import { errors } from "@/server/error"
export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => {
const app = new Hono()
@@ -92,7 +93,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
app.get(InstancePaths.vcsStatus, (c) => handler(c.req.raw, context))
app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
app.get(InstancePaths.vcsDiffRaw, (c) => handler(c.req.raw, context))
app.post(InstancePaths.vcsApply, (c) => handler(c.req.raw, context))
app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
@@ -162,7 +166,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context))
registerKiloHttpApiRoutes(app, handler, context) // kilocode_change
}
@@ -297,6 +301,98 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
return yield* vcs.diff(c.req.valid("query").mode)
}),
)
.get(
"/vcs/status",
describeRoute({
summary: "Get VCS status",
description: "Retrieve changed files in the current working tree without patches.",
operationId: "vcs.status",
responses: {
200: {
description: "VCS status",
content: {
"application/json": {
schema: resolver(Vcs.FileStatus.zod.array()),
},
},
},
},
}),
async (c) =>
jsonRequest("InstanceRoutes.vcs.status", c, function* () {
const vcs = yield* Vcs.Service
return yield* vcs.status()
}),
)
.get(
"/vcs/diff/raw",
describeRoute({
summary: "Get raw VCS diff",
description: "Retrieve a raw patch for current uncommitted changes.",
operationId: "vcs.diff.raw",
responses: {
200: {
description: "Raw VCS diff",
content: {
"text/x-diff": {
schema: resolver(z.string()),
},
},
},
},
}),
async (c) => {
const patch = await runRequest(
"InstanceRoutes.vcs.diffRaw",
c,
Vcs.Service.use((vcs) => vcs.diffRaw()),
)
return c.text(patch, 200, { "content-type": "text/x-diff; charset=utf-8" })
},
)
.post(
"/vcs/apply",
describeRoute({
summary: "Apply VCS patch",
description: "Apply a raw patch to the current working tree.",
operationId: "vcs.apply",
responses: {
200: {
description: "VCS patch applied",
content: {
"application/json": {
schema: resolver(Vcs.ApplyResult.zod),
},
},
},
...errors(400),
},
}),
validator("json", Vcs.ApplyInput.zodObject),
async (c) => {
const result = await runRequest(
"InstanceRoutes.vcs.apply",
c,
Vcs.Service.use((vcs) => vcs.apply(c.req.valid("json") as Vcs.ApplyInput)).pipe(
Effect.match({
onFailure: (error) => ({ ok: false as const, error }),
onSuccess: (value) => ({ ok: true as const, value }),
}),
),
)
if (result.ok) return c.json(result.value)
return c.json(
{
name: "VcsApplyError",
data: {
message: result.error.message,
reason: result.error.reason,
},
},
400,
)
},
)
.get(
"/command",
describeRoute({
@@ -16,6 +16,9 @@ import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
import { Session } from "@/session/session"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { SessionID } from "@/session/schema"
const ReplayEvent = z.object({
id: z.string(),
@@ -24,6 +27,9 @@ const ReplayEvent = z.object({
type: z.string(),
data: z.record(z.string(), z.unknown()),
})
const SessionPayload = z.object({
sessionID: SessionID.zod,
})
const log = Log.create({ service: "server.sync" })
@@ -108,6 +114,47 @@ export const SyncRoutes = lazy(() =>
})
},
)
.post(
"/steal",
describeRoute({
summary: "Steal session into workspace",
description: "Update a session to belong to the current workspace through the sync event system.",
operationId: "sync.steal",
responses: {
200: {
description: "Session stolen into workspace",
content: {
"application/json": {
schema: resolver(SessionPayload),
},
},
},
...errors(400),
},
}),
validator("json", SessionPayload),
async (c) => {
const body = c.req.valid("json")
const workspaceID = WorkspaceContext.workspaceID
if (!workspaceID) throw new Error("Cannot steal session without workspace context")
SyncEvent.run(Session.Event.Updated, {
sessionID: body.sessionID,
info: {
workspaceID,
},
})
log.info("sync session stolen", {
sessionID: body.sessionID,
workspaceID,
})
return c.json({
sessionID: body.sessionID,
})
},
)
.post(
"/history",
describeRoute({
+6 -3
View File
@@ -1,7 +1,7 @@
import fs from "node:fs/promises"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Hono } from "hono"
import { DEFAULT_CSP, embeddedUI } from "../shared/ui"
import { embeddedUI, cspForHtml } from "../shared/ui"
export async function serveUI(request: Request) {
const embeddedWebUI = await embeddedUI()
@@ -14,8 +14,11 @@ export async function serveUI(request: Request) {
if (await fs.exists(match)) {
const mime = AppFileSystem.mimeType(match)
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
return new Response(new Uint8Array(await fs.readFile(match)), { headers })
const body = new Uint8Array(await fs.readFile(match))
if (mime.startsWith("text/html")) {
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
}
return new Response(body, { headers })
}
return Response.json({ error: "Not Found" }, { status: 404 })
+3 -3
View File
@@ -108,10 +108,10 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv
const backendAttributes = ServerBackend.attributes(selection)
const app = new Hono()
.onError(ErrorMiddleware)
.use(AuthMiddleware)
.use(LoggerMiddleware(backendAttributes))
.use(CompressionMiddleware)
.use(CorsMiddleware(opts))
.use(LoggerMiddleware(backendAttributes))
.use(AuthMiddleware)
.use(CompressionMiddleware)
.route("/global", GlobalRoutes())
const runtime = adapter.create(app)
+16 -3
View File
@@ -2,14 +2,25 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
import { HttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createHash } from "node:crypto"
const embeddedUIPromise = Flag.KILO_DISABLE_EMBEDDED_WEB_UI
? Promise.resolve(null)
: // @ts-expect-error - generated file at build time
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
export const DEFAULT_CSP =
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
export const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src * data:`
export const DEFAULT_CSP = csp()
export function themePreloadHash(body: string) {
return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
}
export function cspForHtml(body: string) {
const match = themePreloadHash(body)
return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")
}
export function embeddedUI() {
if (Flag.KILO_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
@@ -23,7 +34,9 @@ function notFound() {
function embeddedUIResponse(file: string, body: Uint8Array) {
const mime = AppFileSystem.mimeType(file)
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
if (mime.startsWith("text/html")) {
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
}
return HttpServerResponse.raw(body, { headers })
}
+47 -4
View File
@@ -958,15 +958,32 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
role: "assistant",
parts: [],
}
// Anthropic adaptive thinking can persist assistant turns like:
// step-start, reasoning(signature), text(""), step-start,
// reasoning(signature). The empty text part is a structural separator,
// but it does not carry the signature metadata itself. Dropping it shifts
// signed thinking positions after step-start splitting/provider regrouping;
// keeping it as "" is filtered by the AI SDK and rejected by Anthropic.
// It is unclear whether this shape originates in our stream processing,
// a proxy, or a lower-level library, but preserving a non-empty separator
// here is the only safe replay point we have.
// Use a single space so the separator survives replay without changing
// the neighboring signed reasoning blocks. Bedrock-hosted Claude stores
// the same signature under the bedrock metadata namespace.
const hasSignedReasoning = msg.parts.some((part) => {
if (part.type !== "reasoning") return false
return part.metadata?.anthropic?.signature != null || part.metadata?.bedrock?.signature != null
})
for (const part of msg.parts) {
// kilocode_change start - keep local UI warnings out of future prompts
if (part.type === "text" && !part.ignored)
// kilocode_change end
// kilocode_change - !part.ignored keeps local UI warnings out of future prompts
if (part.type === "text" && !part.ignored) {
const text = part.text === "" && hasSignedReasoning ? " " : part.text
assistantMessage.parts.push({
type: "text",
text: part.text,
text,
...(differentModel ? {} : { providerMetadata: part.metadata }),
})
}
if (part.type === "step-start")
assistantMessage.parts.push({
type: "step-start",
@@ -1212,6 +1229,32 @@ export function filterCompacted(msgs: Iterable<WithParts>) {
completed.add(msg.info.parentID)
}
result.reverse()
const compactionIndex = result.findLastIndex(
(msg) =>
msg.info.role === "user" &&
msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined),
)
const compaction = result[compactionIndex]
const part = compaction?.parts.find(
(item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined,
)
const summaryIndex = compaction
? result.findIndex(
(msg, index) =>
index > compactionIndex &&
msg.info.role === "assistant" &&
msg.info.summary &&
msg.info.parentID === compaction.info.id,
)
: -1
const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1
if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) {
return [
...result.slice(compactionIndex, summaryIndex + 1),
...result.slice(tailIndex, compactionIndex),
...result.slice(summaryIndex + 1),
]
}
return result
}
+5 -4
View File
@@ -25,6 +25,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { isRecord } from "@/util/record"
import { EventV2 } from "@/v2/event"
import { SessionEvent } from "@/v2/session-event"
import { Modelv2 } from "@/v2/model"
import * as DateTime from "effect/DateTime"
const DOOM_LOOP_THRESHOLD = 3
@@ -508,9 +509,9 @@ export const layer: Layer.Layer<
sessionID: ctx.sessionID,
agent: input.assistantMessage.agent,
model: {
id: ctx.model.id,
providerID: ctx.model.providerID,
variant: input.assistantMessage.variant,
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()),
@@ -786,7 +787,7 @@ export const layer: Layer.Layer<
EventV2.run(SessionEvent.Step.Failed.Sync, {
sessionID: ctx.sessionID,
error: {
type: error.name,
type: "unknown",
message: errorMessage(e),
},
timestamp: DateTime.makeUnsafe(Date.now()),
@@ -151,11 +151,7 @@ export default [
SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => {
db.update(SessionTable)
.set({
model: {
id: data.id,
providerID: data.providerID,
variant: data.variant,
},
model: data.model,
time_updated: DateTime.toEpochMillis(data.timestamp),
})
.where(eq(SessionTable.id, data.sessionID))
+11 -10
View File
@@ -8,7 +8,6 @@ import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kil
import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change
import { Suggestion } from "@/kilocode/suggestion" // kilocode_change
import { Question } from "@/question" // kilocode_change
import z from "zod"
import * as EffectZod from "@/util/effect-zod"
import { SessionID, MessageID, PartID } from "./schema"
import { MessageV2 } from "./message-v2"
@@ -64,6 +63,7 @@ import { SessionRunState } from "./run-state"
import { EffectBridge } from "@/effect/bridge"
import { EventV2 } from "@/v2/event"
import { SessionEvent } from "@/v2/session-event"
import { Modelv2 } from "@/v2/model"
import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
import * as DateTime from "effect/DateTime"
import { eq } from "@/storage/db"
@@ -135,9 +135,8 @@ export const layer = Layer.effect(
return yield* EffectBridge.make()
})
const ops = Effect.fn("SessionPrompt.ops")(function* () {
const run = yield* runner()
return {
cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
cancel: (sessionID: SessionID) => cancel(sessionID),
resolvePromptParts: (template: string) => resolvePromptParts(template),
prompt: (input: PromptInput) => prompt(input),
} satisfies TaskPromptOps
@@ -789,7 +788,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const markReady = ready ? ready.open.pipe(Effect.asVoid) : Effect.void
const { msg, part, cwd } = yield* Effect.gen(function* () {
const ctx = yield* InstanceState.context
const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (session.revert) {
yield* revert.cleanup(session)
}
@@ -1023,9 +1022,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
EventV2.run(SessionEvent.ModelSwitched.Sync, {
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(info.time.created),
id: info.model.modelID,
providerID: info.model.providerID,
variant: info.model.variant,
model: {
id: Modelv2.ID.make(info.model.modelID),
providerID: Modelv2.ProviderID.make(info.model.providerID),
variant: Modelv2.VariantID.make(info.model.variant ?? "default"),
},
})
}
@@ -1414,7 +1415,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID)
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
yield* KiloSessionPrompt.recoverDanglingAssistant({ sessionID: input.sessionID, status, sessions })
@@ -1476,9 +1477,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
let compactionAttempts = 0 // kilocode_change - cap compaction attempts per turn to avoid infinite loops
const ctx = yield* InstanceState.context
const slog = elog.with({ sessionID })
let structured: unknown | undefined
let structured: unknown
let step = 0
const session = yield* sessions.get(sessionID)
const session = yield* sessions.get(sessionID).pipe(Effect.orDie)
while (true) {
yield* status.set(sessionID, { type: "busy" })
+7 -7
View File
@@ -44,7 +44,7 @@ export const layer = Layer.effect(
yield* state.assertNotBusy(input.sessionID)
const all = yield* sessions.messages({ sessionID: input.sessionID })
let lastUser: MessageV2.User | undefined
const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
let rev: Session.Info["revert"]
const patches: Snapshot.Patch[] = []
@@ -77,12 +77,12 @@ export const layer = Layer.effect(
// kilocode_change start - compute diffs BEFORE reverting files so the diff
// reflects changes being undone (files on disk still have AI modifications)
const range = all.filter((msg) => msg.info.id >= rev!.messageID)
const range = all.filter((msg) => msg.info.id >= rev.messageID)
const diffs = yield* summary.computeDiff({ messages: range })
// kilocode_change end
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
// kilocode_change start
@@ -103,17 +103,17 @@ export const layer = Layer.effect(
diffs: summaryDiffs, // kilocode_change
},
})
return yield* sessions.get(input.sessionID)
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
})
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", input)
yield* state.assertNotBusy(input.sessionID)
const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (!session.revert) return session
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
if (session.revert.snapshot) yield* snap.restore(session.revert.snapshot)
yield* sessions.clearRevert(input.sessionID)
return yield* sessions.get(input.sessionID)
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
})
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
+8 -7
View File
@@ -3,7 +3,6 @@ import path from "path"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
import z from "zod"
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -441,6 +440,8 @@ export class BusyError extends Error {
}
}
export type NotFound = InstanceType<typeof NotFoundError>
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly create: (input?: {
@@ -452,9 +453,9 @@ export interface Interface {
platform?: string // kilocode_change - per-session platform override for telemetry attribution
workspaceID?: WorkspaceID
}) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
readonly get: (id: SessionID) => Effect.Effect<Info>
readonly get: (id: SessionID) => Effect.Effect<Info, NotFound>
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
@@ -468,7 +469,7 @@ export interface Interface {
readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<MessageV2.WithParts[]>
readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
readonly remove: (sessionID: SessionID) => Effect.Effect<void>
readonly remove: (sessionID: SessionID) => Effect.Effect<void, NotFound>
readonly updateMessage: <T extends MessageV2.Info>(msg: T) => Effect.Effect<T>
readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<MessageID>
readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect<PartID>
@@ -559,13 +560,13 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
const get = Effect.fn("Session.get")(function* (id: SessionID) {
const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
if (!row) return yield* Effect.fail(new NotFoundError({ message: `Session not found: ${id}` }))
return fromRow(row)
})
const list = Effect.fn("Session.list")(function* (input?: ListInput) {
const ctx = yield* InstanceState.context
return Array.from(listByProject({ projectID: ctx.project.id, ...(input ?? {}) }))
return Array.from(listByProject({ projectID: ctx.project.id, ...input }))
})
// kilocode_change start - scope by project_id when instance context is available
@@ -585,8 +586,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
// kilocode_change end
const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
const session = yield* get(sessionID)
try {
const session = yield* get(sessionID)
const kids = yield* children(sessionID)
for (const child of kids) {
yield* remove(child.id)
+1
View File
@@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
export const EventSequenceTable = sqliteTable("event_sequence", {
aggregate_id: text().notNull().primaryKey(),
seq: integer().notNull(),
owner_id: text(),
})
export const EventTable = sqliteTable("event", {
+25 -9
View File
@@ -59,8 +59,11 @@ export interface Interface {
data: Event<Def>["data"],
options?: { publish?: boolean },
) => Effect.Effect<void>
readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect<void>
readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect<string | undefined>
readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect<void>
readonly replayAll: (
events: SerializedEvent[],
options?: { publish: boolean; ownerID?: string },
) => Effect.Effect<string | undefined>
readonly remove: (aggregateID: string) => Effect.Effect<void>
}
@@ -76,7 +79,7 @@ export const layer = Layer.effect(Service)(
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq })
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
.get(),
@@ -85,6 +88,10 @@ export const layer = Layer.effect(Service)(
const latest = row?.seq ?? -1
if (event.seq <= latest) return
if (row?.ownerID && row.ownerID !== options?.ownerID) {
return
}
const expected = latest + 1
if (event.seq !== expected) {
throw new Error(
@@ -99,7 +106,7 @@ export const layer = Layer.effect(Service)(
workspace: yield* InstanceState.workspaceID,
}
: undefined
process(def, event, { publish, context })
process(def, event, { publish, context, ownerID: options?.ownerID })
})
const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) {
@@ -263,7 +270,7 @@ export function project<Def extends Definition>(
function process<Def extends Definition>(
def: Def,
event: Event<Def>,
options: { publish: boolean; context?: PublishContext },
options: { publish: boolean; context?: PublishContext; ownerID?: string },
) {
if (projectors == null) {
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
@@ -274,8 +281,6 @@ function process<Def extends Definition>(
throw new Error(`Projector not found for event: ${def.type}`)
}
// idempotent: need to ignore any events already logged
Database.transaction((tx) => {
projector(tx, event.data, event)
@@ -284,6 +289,7 @@ function process<Def extends Definition>(
.values({
aggregate_id: event.aggregateID,
seq: event.seq,
owner_id: options?.ownerID,
})
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
@@ -332,11 +338,11 @@ function process<Def extends Definition>(
})
}
export function replay(event: SerializedEvent, options?: { publish: boolean }) {
export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) {
return runtime.runSync((sync) => sync.replay(event, options))
}
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) {
return runtime.runSync((sync) => sync.replayAll(events, options))
}
@@ -348,6 +354,16 @@ export function remove(aggregateID: string) {
return runtime.runSync((sync) => sync.remove(aggregateID))
}
export function claim(aggregateID: string, ownerID: string) {
Database.use((db) =>
db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run(),
)
}
export function payloads() {
return registry
.entries()
+19 -10
View File
@@ -9,10 +9,11 @@ import { Config } from "@/config/config"
import { KiloTask } from "../kilocode/tool/task" // kilocode_change
import { KiloCostPropagation } from "../kilocode/session/cost-propagation" // kilocode_change
import { KiloSessionProcessor } from "../kilocode/session/processor" // kilocode_change
import { Effect, Schema } from "effect"
import { Effect, Exit, Schema } from "effect"
import { EffectBridge } from "@/effect/bridge"
export interface TaskPromptOps {
cancel(sessionID: SessionID): void
cancel(sessionID: SessionID): Effect.Effect<void>
resolvePromptParts(template: string): Effect.Effect<SessionPrompt.PromptInput["parts"]>
prompt(input: SessionPrompt.PromptInput): Effect.Effect<MessageV2.WithParts>
}
@@ -138,17 +139,19 @@ export const TaskTool = Tool.define(
const ops = ctx.extra?.promptOps as TaskPromptOps
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
const runCancel = yield* EffectBridge.make()
const messageID = MessageID.ascending()
const cancel = ops.cancel(nextSession.id)
function cancel() {
ops.cancel(nextSession.id)
function onAbort() {
runCancel.fork(cancel)
}
return yield* Effect.acquireUseRelease(
// kilocode_change start - snapshot child cost so we propagate only the delta on resume (#6321)
Effect.gen(function* () {
ctx.abort.addEventListener("abort", cancel)
ctx.abort.addEventListener("abort", onAbort)
return yield* KiloCostPropagation.childCost(sessions, nextSession.id)
}),
// kilocode_change end
@@ -190,12 +193,18 @@ export const TaskTool = Tool.define(
}
}),
// kilocode_change start - propagate subagent cost delta to parent on every exit path (#6321)
(costBefore) =>
(costBefore, exit) =>
Effect.gen(function* () {
ctx.abort.removeEventListener("abort", cancel)
const costAfter = yield* KiloCostPropagation.childCost(sessions, nextSession.id)
yield* KiloCostPropagation.propagate(sessions, ctx.sessionID, ctx.messageID, costAfter - costBefore)
}),
if (Exit.hasInterrupts(exit)) yield* cancel
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
ctx.abort.removeEventListener("abort", onAbort)
const costAfter = yield* KiloCostPropagation.childCost(sessions, nextSession.id)
yield* KiloCostPropagation.propagate(sessions, ctx.sessionID, ctx.messageID, costAfter - costBefore)
}),
),
),
// kilocode_change end
)
})
+5
View File
@@ -63,6 +63,11 @@ export function truncate(str: string, len: number): string {
return str.slice(0, len - 1) + "…"
}
export function truncateLeft(str: string, len: number): string {
if (str.length <= len) return str
return "…" + str.slice(-(len - 1))
}
export function truncateMiddle(str: string, maxLength: number = 35): string {
if (str.length <= maxLength) return str
+246
View File
@@ -0,0 +1,246 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "@opencode-ai/core/util/identifier"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const AccountID = Schema.String.pipe(
Schema.brand("AccountID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type AccountID = typeof AccountID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("AuthV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AuthV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AuthV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Account extends Schema.Class<Account>("AuthV2.Account")({
id: AccountID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class AuthFileWriteError extends Schema.TaggedErrorClass<AuthFileWriteError>()("AuthV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type AuthError = AuthFileWriteError
interface Writable {
version: 2
accounts: Record<string, Account>
active: Record<string, AccountID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Account> = {}
const active: Record<string, AccountID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const accountID = AccountID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Account({
id: accountID,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = accountID
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (accountID: AccountID) => Effect.Effect<Account | undefined, AuthError>
readonly all: () => Effect.Effect<Account[], AuthError>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
active?: boolean
}) => Effect.Effect<Account, AuthError>
readonly update: (
accountID: AccountID,
updates: Partial<Pick<Account, "description" | "credential">>,
) => Effect.Effect<void, AuthError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly activate: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly active: (serviceID: ServiceID) => Effect.Effect<Account | undefined, AuthError>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Account[], AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const file = path.join(global.data, "auth-v2.json")
const load: () => Effect.Effect<Writable, AuthError> = Effect.fnUntraced(function* () {
if (process.env.KILO_AUTH_CONTENT) {
try {
return JSON.parse(process.env.KILO_AUTH_CONTENT)
} catch {}
}
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (!raw || typeof raw !== "object") return { version: 2, accounts: {}, active: {} }
if ("version" in raw && raw.version === 2) return raw as Writable
const migrated = migrate(raw as Record<string, unknown>)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause })))
return migrated
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(yield* load())
const result: Interface = {
get: Effect.fn("AuthV2.get")(function* (accountID) {
return (yield* SynchronizedRef.get(state)).accounts[accountID]
}),
all: Effect.fn("AuthV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AuthV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AuthV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AuthV2.add")(function* (input) {
return yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = new Account({
id: AccountID.make(Identifier.ascending()),
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active:
(input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID))
? { ...data.active, [input.serviceID]: account.id }
: data.active,
}
yield* write(next)
return [account, next] as const
}),
)
}),
update: Effect.fn("AuthV2.update")(function* (accountID, updates) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const existing = data.accounts[accountID]
if (!existing) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[accountID]: new Account({
id: accountID,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AuthV2.remove")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID)
delete active[accounts[accountID].serviceID]
delete accounts[accountID]
const next = { ...data, accounts, active }
yield* write(next)
return [undefined, next] as const
}),
)
}),
activate: Effect.fn("AuthV2.activate")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = data.accounts[accountID]
if (!account) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } }
yield* write(next)
return [undefined, next] as const
}),
)
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))
export * as AuthV2 from "./auth"
+192
View File
@@ -0,0 +1,192 @@
import { withStatics } from "@/util/schema"
import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
export const ID = Schema.String.pipe(Schema.brand("Model.ID"))
export type ID = typeof ID.Type
export const ProviderID = Schema.String.pipe(
Schema.brand("Model.ProviderID"),
withStatics((schema) => ({
// Well-known providers
opencode: schema.make("opencode"),
anthropic: schema.make("anthropic"),
openai: schema.make("openai"),
google: schema.make("google"),
googleVertex: schema.make("google-vertex"),
githubCopilot: schema.make("github-copilot"),
amazonBedrock: schema.make("amazon-bedrock"),
azure: schema.make("azure"),
openrouter: schema.make("openrouter"),
mistral: schema.make("mistral"),
gitlab: schema.make("gitlab"),
})),
)
export type ProviderID = typeof ProviderID.Type
export const VariantID = Schema.String.pipe(Schema.brand("VariantID"))
export type VariantID = typeof VariantID.Type
// Grouping of models, eg claude opus, claude sonnet
export const Family = Schema.String.pipe(Schema.brand("Family"))
export type Family = typeof Family.Type
const OpenAIResponses = Schema.Struct({
type: Schema.Literal("openai/responses"),
url: Schema.String,
websocket: Schema.optional(Schema.Boolean),
})
const OpenAICompletions = Schema.Struct({
type: Schema.Literal("openai/completions"),
url: Schema.String,
reasoning: Schema.Union([
Schema.Struct({
type: Schema.Literal("reasoning_content"),
}),
Schema.Struct({
type: Schema.Literal("reasoning_details"),
}),
]).pipe(Schema.optional),
})
export type OpenAICompletions = typeof OpenAICompletions.Type
const AnthropicMessages = Schema.Struct({
type: Schema.Literal("anthropic/messages"),
url: Schema.String,
})
export const Endpoint = Schema.Union([OpenAIResponses, OpenAICompletions, AnthropicMessages]).pipe(
Schema.toTaggedUnion("type"),
)
export type Endpoint = typeof Endpoint.Type
export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
// mime patterns, image, audio, video/*, text/*
input: Schema.String.pipe(Schema.Array),
output: Schema.String.pipe(Schema.Array),
})
export type Capabilities = typeof Capabilities.Type
export const Options = Schema.Struct({
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.Record(Schema.String, Schema.Any),
})
export type Options = typeof Options.Type
export const Cost = Schema.Struct({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(Schema.optional),
input: Schema.Finite,
output: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
})
export const Ref = Schema.Struct({
id: ID,
providerID: ProviderID,
variant: VariantID,
})
export type Ref = typeof Ref.Type
export class Info extends Schema.Class<Info>("Model.Info")({
id: ID,
providerID: ProviderID,
family: Family.pipe(Schema.optional),
name: Schema.String,
endpoint: Endpoint,
capabilities: Capabilities,
options: Schema.Struct({
...Options.fields,
variant: Schema.String.pipe(Schema.optional),
}),
variants: Schema.Struct({
id: VariantID,
...Options.fields,
}).pipe(Schema.Array),
time: Schema.Struct({
released: DateTimeUtcFromMillis,
}),
cost: Cost.pipe(Schema.Array),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
limit: Schema.Struct({
context: Schema.Int,
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int,
}),
}) {}
export function parse(input: string): { providerID: ProviderID; modelID: ID } {
const [providerID, ...modelID] = input.split("/")
return {
providerID: ProviderID.make(providerID),
modelID: ID.make(modelID.join("/")),
}
}
export interface Interface {
readonly get: (providerID: ProviderID, modelID: ID) => Effect.Effect<Option.Option<Info>>
readonly add: (model: Info) => Effect.Effect<void>
readonly remove: (providerID: ProviderID, modelID: ID) => Effect.Effect<void>
readonly all: () => Effect.Effect<Info[]>
readonly default: () => Effect.Effect<Option.Option<Info>>
readonly small: (provider: ProviderID) => Effect.Effect<Option.Option<Info>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Model") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
let models = HashMap.empty<string, Info>()
function key(providerID: ProviderID, modelID: ID) {
return `${providerID}/${modelID}`
}
const result: Interface = {
get: Effect.fn("V2Model.get")(function* (providerID, modelID) {
return HashMap.get(models, key(providerID, modelID))
}),
add: Effect.fn("V2Model.add")(function* (model) {
models = HashMap.set(models, key(model.providerID, model.id), model)
}),
remove: Effect.fn("V2Model.remove")(function* (providerID, modelID) {
models = HashMap.remove(models, key(providerID, modelID))
}),
all: Effect.fn("V2Model.all")(function* () {
return pipe(
models,
HashMap.toValues,
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
}),
default: Effect.fn("V2Model.default")(function* () {
const all = yield* result.all()
return Option.fromUndefinedOr(all[0])
}),
small: Effect.fn("V2Model.small")(function* (providerID) {
const all = yield* result.all()
const match = all.find((model) => model.providerID === providerID && model.id.toLowerCase().includes("small"))
return Option.fromUndefinedOr(match)
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer
export * as Modelv2 from "./model"
+10 -13
View File
@@ -5,8 +5,8 @@ import { FileAttachment, Prompt } from "./session-prompt"
import { Schema } from "effect"
export { FileAttachment }
import { ToolOutput } from "./tool-output"
import { ModelID, ProviderID } from "@/provider/schema"
import { V2Schema } from "./schema"
import { Modelv2 } from "./model"
export const Source = Schema.Struct({
start: NonNegativeInt,
@@ -22,10 +22,13 @@ const Base = {
sessionID: SessionID,
}
const Error = Schema.Struct({
type: Schema.String,
export const UnknownError = Schema.Struct({
type: Schema.Literal("unknown"),
message: Schema.String,
}).annotate({
identifier: "Session.Error.Unknown",
})
export type UnknownError = Schema.Schema.Type<typeof UnknownError>
export const AgentSwitched = EventV2.define({
type: "session.next.agent.switched",
@@ -44,9 +47,7 @@ export const ModelSwitched = EventV2.define({
version: 1,
schema: {
...Base,
id: ModelID,
providerID: ProviderID,
variant: Schema.String.pipe(Schema.optional),
model: Modelv2.Ref,
},
})
export type ModelSwitched = Schema.Schema.Type<typeof ModelSwitched>
@@ -103,11 +104,7 @@ export namespace Step {
schema: {
...Base,
agent: Schema.String,
model: Schema.Struct({
id: Schema.String,
providerID: Schema.String,
variant: Schema.String.pipe(Schema.optional),
}),
model: Modelv2.Ref,
snapshot: Schema.String.pipe(Schema.optional),
},
})
@@ -139,7 +136,7 @@ export namespace Step {
aggregate: "sessionID",
schema: {
...Base,
error: Error,
error: UnknownError,
},
})
export type Failed = Schema.Schema.Type<typeof Failed>
@@ -296,7 +293,7 @@ export namespace Tool {
schema: {
...Base,
callID: Schema.String,
error: Error,
error: UnknownError,
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
@@ -109,11 +109,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
id: event.id,
type: "model-switched",
metadata: event.metadata,
model: {
id: event.data.id,
providerID: event.data.providerID,
variant: event.data.variant,
},
model: event.data.model,
time: { created: event.data.timestamp },
}),
)
+3 -9
View File
@@ -4,6 +4,7 @@ import { SessionEvent } from "./session-event"
import { EventV2 } from "./event"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./schema"
import { Modelv2 } from "./model"
export const ID = EventV2.ID
export type ID = Schema.Schema.Type<typeof ID>
@@ -25,11 +26,7 @@ export class AgentSwitched extends Schema.Class<AgentSwitched>("Session.Message.
export class ModelSwitched extends Schema.Class<ModelSwitched>("Session.Message.ModelSwitched")({
...Base,
type: Schema.Literal("model-switched"),
model: Schema.Struct({
id: SessionEvent.ModelSwitched.fields.data.fields.id,
providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID,
variant: SessionEvent.ModelSwitched.fields.data.fields.variant,
}),
model: Modelv2.Ref,
}) {}
export class User extends Schema.Class<User>("Session.Message.User")({
@@ -87,10 +84,7 @@ export class ToolStateError extends Schema.Class<ToolStateError>("Session.Messag
input: Schema.Record(Schema.String, Schema.Unknown),
content: ToolOutput.Content.pipe(Schema.Array),
structured: ToolOutput.Structured,
error: Schema.Struct({
type: Schema.String,
message: Schema.String,
}),
error: SessionEvent.UnknownError,
}) {}
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
+56 -20
View File
@@ -3,17 +3,17 @@ import { SessionID } from "@/session/schema"
import { WorkspaceID } from "@/control-plane/schema"
import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db"
import * as Database from "@/storage/db"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { Context, DateTime, Effect, Layer, Option, Schema } from "effect"
import { SessionMessage } from "./session-message"
import type { Prompt } from "./session-prompt"
import { EventV2 } from "./event"
import { ProjectID } from "@/project/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionEvent } from "./session-event"
import { V2Schema } from "./schema"
import { optionalOmitUndefined } from "@/util/schema"
import { Modelv2 } from "./model"
export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
identifier: "Session.Delivery",
})
export type Delivery = Schema.Schema.Type<typeof Delivery>
@@ -27,11 +27,7 @@ export class Info extends Schema.Class<Info>("Session.Info")({
workspaceID: optionalOmitUndefined(WorkspaceID),
path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String),
model: Schema.Struct({
id: ModelID,
providerID: ProviderID,
variant: optionalOmitUndefined(Schema.String),
}).pipe(optionalOmitUndefined),
model: Modelv2.Ref.pipe(optionalOmitUndefined),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis,
@@ -53,7 +49,18 @@ export class Info extends Schema.Class<Info>("Session.Info")({
*/
}) {}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
sessionID: SessionID,
}) {}
export interface Interface {
readonly create: (input?: {
agent?: string
model?: Modelv2.Ref
parentID?: SessionID
workspaceID?: WorkspaceID
}) => Effect.Effect<Info>
readonly get: (sessionID: SessionID) => Effect.Effect<Info, NotFoundError>
readonly list: (input: {
limit?: number
order?: "asc" | "desc"
@@ -88,13 +95,15 @@ export interface Interface {
}) => Effect.Effect<SessionMessage.User, never>
readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never>
readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never>
readonly subagent: (input: {
id?: EventV2.ID
parentID: SessionID
prompt: Prompt
agent: string
model?: Modelv2.Ref
}) => Effect.Effect<void, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
readonly switchModel: (input: {
sessionID: SessionID
id: ModelID
providerID: ProviderID
variant?: string
}) => Effect.Effect<void, never>
readonly switchModel: (input: { sessionID: SessionID; model: Modelv2.Ref }) => Effect.Effect<void, never>
readonly compact: (sessionID: SessionID) => Effect.Effect<void, never>
readonly wait: (sessionID: SessionID) => Effect.Effect<void, never>
}
@@ -120,9 +129,9 @@ export const layer = Layer.effect(
agent: row.agent ?? undefined,
model: row.model
? {
id: ModelID.make(row.model.id),
providerID: ProviderID.make(row.model.providerID),
variant: row.model.variant,
id: Modelv2.ID.make(row.model.id),
providerID: Modelv2.ProviderID.make(row.model.providerID),
variant: Modelv2.VariantID.make(row.model.variant ?? "default"),
}
: undefined,
time: {
@@ -134,6 +143,14 @@ export const layer = Layer.effect(
}
const result: Interface = {
create: Effect.fn("V2Session.create")(function* (_input) {
return {} as any
}),
get: Effect.fn("V2Session.get")(function* (sessionID) {
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())
if (!row) return yield* new NotFoundError({ sessionID })
return fromRow(row)
}),
list: Effect.fn("V2Session.list")(function* (input) {
const direction = input.cursor?.direction ?? "next"
let order = input.order ?? "desc"
@@ -262,11 +279,30 @@ export const layer = Layer.effect(
EventV2.run(SessionEvent.ModelSwitched.Sync, {
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()),
id: input.id,
providerID: input.providerID,
variant: input.variant,
model: input.model,
})
}),
subagent: Effect.fn("V2Session.subagent")(function* (input) {
const parent = yield* result.get(input.parentID)
const session = yield* result.create({
agent: input.agent,
model: input.model,
parentID: input.parentID,
workspaceID: parent.workspaceID,
})
yield* result.prompt({
prompt: input.prompt,
sessionID: session.id,
})
yield* Effect.gen(function* () {
yield* result.wait(session.id)
const messages = yield* result.messages({ sessionID: session.id, order: "desc" })
const assistant = messages.find((msg) => msg.type === "assistant")
if (!assistant) return
const text = assistant.content.findLast((part) => part.type === "text")
if (!text) return
}).pipe(Effect.forkChild())
}),
compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}),
wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}),
}
@@ -34,10 +34,11 @@ describe("acp.agent interface compliance", () => {
"loadSession",
"setSessionMode",
"authenticate",
// Unstable - SDK checks these with unstable_ prefix
// Capability-gated methods checked by the SDK router
"listSessions",
"resumeSession",
"closeSession",
"unstable_forkSession",
"unstable_resumeSession",
"unstable_setSessionModel",
]
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test"
import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create"
describe("recentConnectedWorkspaces", () => {
test("returns unique connected workspaces after filtering missing and inactive entries", () => {
const workspaces = [
{ id: "wrk_a", name: "alpha" },
{ id: "wrk_b", name: "beta" },
{ id: "wrk_c", name: "gamma" },
{ id: "wrk_d", name: "delta" },
{ id: "wrk_e", name: "epsilon" },
]
const status = {
wrk_a: "connected",
wrk_b: "disconnected",
wrk_c: "error",
wrk_d: "connected",
wrk_e: "connected",
} as const
const { recent } = recentConnectedWorkspaces({
sessions: [
{ time: { updated: 900 } },
{ workspaceID: "wrk_b", time: { updated: 800 } },
{ workspaceID: "wrk_a", time: { updated: 700 } },
{ workspaceID: "wrk_a", time: { updated: 600 } },
{ workspaceID: "wrk_missing", time: { updated: 500 } },
{ workspaceID: "wrk_c", time: { updated: 400 } },
{ workspaceID: "wrk_d", time: { updated: 300 } },
{ workspaceID: "wrk_e", time: { updated: 200 } },
],
get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
status: (workspaceID) => status[workspaceID as keyof typeof status],
})
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"])
})
test("omits the active workspace before limiting recent workspaces", () => {
const workspaces = [
{ id: "wrk_a", name: "alpha" },
{ id: "wrk_b", name: "beta" },
{ id: "wrk_c", name: "gamma" },
{ id: "wrk_d", name: "delta" },
]
const { recent, hasMore } = recentConnectedWorkspaces({
sessions: [
{ workspaceID: "wrk_a", time: { updated: 400 } },
{ workspaceID: "wrk_b", time: { updated: 300 } },
{ workspaceID: "wrk_c", time: { updated: 200 } },
{ workspaceID: "wrk_d", time: { updated: 100 } },
],
get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
status: () => "connected",
limit: 3,
omitWorkspaceID: "wrk_a",
})
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"])
expect(hasMore).toBe(false)
})
})
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { normalizeCustomProviderID, providerOptions } from "../../../../src/cli/cmd/tui/component/dialog-provider"
describe("providerOptions", () => {
test("includes a synthetic Other option for custom providers", () => {
expect(providerOptions([{ id: "openai", name: "OpenAI" }]).at(-1)).toMatchObject({
title: "Other",
description: "Custom provider",
category: "Providers",
})
})
test("does not use Other as the generic provider category", () => {
expect(providerOptions([{ id: "mistral", name: "Mistral" }])[0]?.category).toBe("Providers")
})
test("does not collide with a configured provider named other", () => {
const values = providerOptions([{ id: "other", name: "Other Provider" }]).map((option) => option.value)
expect(new Set(values).size).toBe(values.length)
})
test("normalizes and validates custom provider ids", () => {
expect(normalizeCustomProviderID(" custom-provider ")).toBe("custom-provider")
expect(normalizeCustomProviderID("custom_provider")).toBe("custom_provider")
expect(normalizeCustomProviderID("@ai-sdk/custom-provider")).toBe("custom-provider")
expect(normalizeCustomProviderID("-custom-provider")).toBeUndefined()
expect(normalizeCustomProviderID("Custom Provider")).toBeUndefined()
})
})
@@ -59,6 +59,39 @@ function createWebSocketImpl(...sockets: FakeWebSocket[]) {
} as unknown as typeof WebSocket
}
function sendSelection(socket: FakeWebSocket, filePath: string, text = "foo") {
socket.message(
JSON.stringify({
jsonrpc: "2.0",
method: "selection_changed",
params: {
text,
filePath,
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
}),
)
}
function expectedSelection(filePath: string, text = "foo") {
return {
filePath,
source: "websocket" as const,
ranges: [
{
text,
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
],
}
}
test("useEditorContext reconnect switches editor server by session directory", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
@@ -93,12 +126,18 @@ test("useEditorContext reconnect switches editor server by session directory", a
await nextTick()
expect(firstSocket.closed).toBeFalse()
sendSelection(firstSocket, path.join(startupDirectory, "file.ts"))
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
expect(mounted.editor.labelState()).toBe("pending")
mounted.editor.reconnect(sessionDirectory)
await nextTick()
expect(firstSocket.closed).toBeTrue()
expect(secondSocket.closed).toBeFalse()
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.labelState()).toBe("none")
mounted.dispose()
})
@@ -131,7 +170,7 @@ test("useEditorContext favors configured port over lock files", async () => {
mounted.dispose()
})
test("useEditorContext resets selection when reconnecting", async () => {
test("useEditorContext clears selection when reconnecting", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
@@ -169,45 +208,66 @@ test("useEditorContext resets selection when reconnecting", async () => {
},
}),
)
socket.message(
JSON.stringify({
jsonrpc: "2.0",
method: "selection_changed",
params: {
text: "foo",
filePath: path.join(startupDirectory, "file.ts"),
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
}),
)
sendSelection(socket, path.join(startupDirectory, "file.ts"))
expect(mounted.editor.connected()).toBeTrue()
expect(mounted.editor.server()).toEqual({
protocolVersion: "2025-11-25",
serverInfo: { name: "test", version: "0.0.0" },
})
expect(mounted.editor.selection()).toEqual({
filePath: path.join(startupDirectory, "file.ts"),
source: "websocket",
ranges: [
{
text: "foo",
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
],
})
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
expect(mounted.editor.labelState()).toBe("pending")
mounted.editor.markSelectionSent()
expect(mounted.editor.labelState()).toBe("sent")
mounted.editor.reconnect(startupDirectory)
expect(socket.closed).toBeFalse()
expect(mounted.editor.connected()).toBeTrue()
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.labelState()).toBe("none")
mounted.dispose()
})
test("useEditorContext preserves selection for the next reconnect when requested", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
await mkdir(startupDirectory, { recursive: true })
await mkdir(ideDirectory, { recursive: true })
await writeFile(
path.join(ideDirectory, "3001.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [startupDirectory],
}),
)
process.env.CLAUDE_CODE_SSE_PORT = undefined
process.env.KILO_EDITOR_SSE_PORT = undefined
spyOn(process, "cwd").mockImplementation(() => startupDirectory)
spyOn(os, "homedir").mockImplementation(() => tmp.path)
const socket = new FakeWebSocket("ws://127.0.0.1:3001")
const mounted = mountEditorContext(createWebSocketImpl(socket))
await nextTick()
sendSelection(socket, path.join(startupDirectory, "file.ts"))
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
mounted.editor.markSelectionSent()
mounted.editor.preserveSelectionFromNewSession()
mounted.editor.reconnect(startupDirectory)
expect(socket.closed).toBeFalse()
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
expect(mounted.editor.labelState()).toBe("sent")
mounted.editor.reconnect(startupDirectory)
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.labelState()).toBe("none")
mounted.dispose()
})
@@ -2049,6 +2049,83 @@ test("wellknown URL with trailing slash is normalized", async () => {
}
})
test("wellknown remote_config supports templated env vars in headers", async () => {
const originalFetch = globalThis.fetch
const originalToken = process.env.TEST_TOKEN
let wellknownFetchedUrl: string | undefined
let remoteFetchedUrl: string | undefined
let remoteHeaders: HeadersInit | undefined
globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => {
const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
if (urlStr.includes(".well-known/opencode")) {
wellknownFetchedUrl = urlStr
return Promise.resolve(
new Response(
JSON.stringify({
remote_config: {
url: "https://config.example.com/opencode.json",
headers: {
Authorization: "Bearer {env:TEST_TOKEN}",
},
},
}),
{ status: 200 },
),
)
}
if (urlStr.includes("config.example.com")) {
remoteFetchedUrl = urlStr
remoteHeaders = init?.headers
return Promise.resolve(
new Response(
JSON.stringify({
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
}),
{ status: 200 },
),
)
}
return originalFetch(url, init)
}) as unknown as typeof fetch
const fakeAuth = Layer.mock(Auth.Service)({
all: () =>
Effect.succeed({
"https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
}),
})
const layer = Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
)
try {
await provideTmpdirInstance(
() =>
Config.Service.use((svc) =>
Effect.gen(function* () {
const config = yield* svc.get()
expect(wellknownFetchedUrl).toBe("https://example.com/.well-known/opencode")
expect(remoteFetchedUrl).toBe("https://config.example.com/opencode.json")
expect(remoteHeaders).toEqual({ Authorization: "Bearer test-token" })
expect(config.mcp?.confluence?.enabled).toBe(true)
}),
),
{ git: true },
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
} finally {
globalThis.fetch = originalFetch
if (originalToken === undefined) delete process.env.TEST_TOKEN
else process.env.TEST_TOKEN = originalToken
}
})
describe("resolvePluginSpec", () => {
test("keeps package specs unchanged", async () => {
await using tmp = await tmpdir()
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { $ } from "bun"
import fs from "node:fs/promises"
import Http from "node:http"
import path from "node:path"
@@ -6,7 +7,7 @@ import { setTimeout as delay } from "node:timers/promises"
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { asc, eq } from "drizzle-orm"
import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
@@ -16,11 +17,10 @@ import { ProjectTable } from "@/project/project.sql"
import { Instance } from "@/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Session as SessionNs } from "@/session/session"
import { SessionID, MessageID, PartID } from "@/session/schema"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import { ModelID, ProviderID } from "@/provider/schema"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { EventSequenceTable } from "@/sync/event.sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -30,12 +30,17 @@ import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
import * as WorkspaceOld from "../../src/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap"
void Log.init({ print: false })
const testServerLayer = Layer.mergeAll(
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
WorkspaceOld.defaultLayer,
WorkspaceOld.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
),
SessionNs.defaultLayer,
)
const it = testEffect(testServerLayer)
@@ -108,11 +113,23 @@ async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
})
}
async function initGitRepo(dir: string) {
await fs.mkdir(dir, { recursive: true })
await $`git init`.cwd(dir).quiet()
await $`git config core.fsmonitor false`.cwd(dir).quiet()
await $`git config commit.gpgsign false`.cwd(dir).quiet()
await $`git config user.email "test@opencode.test"`.cwd(dir).quiet()
await $`git config user.name "Test"`.cwd(dir).quiet()
await fs.writeFile(path.join(dir, "tracked.txt"), "base\n")
await $`git add tracked.txt`.cwd(dir).quiet()
await $`git commit -m "base"`.cwd(dir).quiet()
}
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input)))
const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
@@ -317,48 +334,24 @@ function sessionSequence(sessionID: SessionID) {
)?.seq
}
function eventRows(sessionID: SessionID) {
function sessionSequenceOwner(sessionID: SessionID) {
return Database.use((db) =>
db
.select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
.select({ ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.get(),
)?.ownerID
}
function sessionUpdatedType() {
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
}
function replaceSessionEvents(sessionID: SessionID, count: number) {
Database.use((db) => {
db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run()
if (count === 0) return
db.insert(EventSequenceTable)
.values({ aggregate_id: sessionID, seq: count - 1 })
.run()
db.insert(EventTable)
.values(
Array.from({ length: count }, (_, i) => ({
id: `evt_${unique(`manual-${i}`)}`,
aggregate_id: sessionID,
seq: i,
type: sessionUpdatedType(),
data: { sessionID, info: { title: `manual ${i}` } },
})),
)
.run()
})
}
describe("workspace-old schemas and exports", () => {
test("keeps the historical event type names", () => {
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore")
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
})
@@ -375,17 +368,6 @@ describe("workspace-old schemas and exports", () => {
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
})
test("validates session restore input", () => {
const input = {
workspaceID: WorkspaceID.ascending("wrk_schema_restore"),
sessionID: SessionID.descending("ses_schema_restore"),
}
expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input)
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow()
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow()
})
})
describe("workspace-old CRUD", () => {
@@ -651,6 +633,176 @@ describe("workspace-old CRUD", () => {
expect(await getWorkspace(info.id)).toBeUndefined()
})
})
test("sessionWarp moves a session into a local workspace and claims ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-prev-local")
const targetType = unique("warp-target-local")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id })
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBe(target.id)
expect(sessionSequenceOwner(session.id)).toBe(target.id)
})
})
test("sessionWarp applies source workspace patch to local target workspace", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-patch-prev-local")
const targetType = unique("warp-patch-target-local")
const previousDir = path.join(dir, "warp-patch-prev-local")
const targetDir = path.join(dir, "warp-patch-target-local")
await initGitRepo(previousDir)
await initGitRepo(targetDir)
await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n")
await fs.writeFile(path.join(previousDir, "new.txt"), "new\n")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
expect(await fs.readFile(path.join(targetDir, "tracked.txt"), "utf8")).toBe("changed\n")
expect(await fs.readFile(path.join(targetDir, "new.txt"), "utf8")).toBe("new\n")
})
})
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-detach-local")
const previous = workspaceInfo(Instance.project.id, previousType)
insertWorkspace(previous)
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: null, sessionID: session.id })
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id)
})
})
it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => {
const calls: FetchCall[] = []
let historySessionID: SessionID | undefined
let historyNextSeq = 0
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
}
calls.push(call)
if (call.url.pathname === "/warp-source/sync/history") {
return yield* HttpServerResponse.json([
{
id: `evt_${unique("warp-source-history")}`,
aggregate_id: historySessionID!,
seq: historyNextSeq,
type: sessionUpdatedType(),
data: { sessionID: historySessionID!, info: { title: "from source history" } },
},
])
}
if (call.url.pathname === "/warp-source/vcs/diff/raw") return HttpServerResponse.text("remote patch")
if (call.url.pathname === "/warp-target/sync/replay")
return yield* HttpServerResponse.json({ sessionID: "ok" })
if (call.url.pathname === "/warp-target/sync/steal")
return yield* HttpServerResponse.json({ sessionID: "ok" })
if (call.url.pathname === "/warp-target/vcs/apply") return yield* HttpServerResponse.json({ applied: true })
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-remote-source")
const targetType = unique("warp-remote-target")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" })
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter)
registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
historySessionID = session.id
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
"POST /warp-source/sync/history",
"GET /warp-source/vcs/diff/raw",
"POST /warp-target/vcs/apply",
"POST /warp-target/sync/replay",
"POST /warp-target/sync/steal",
])
expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
expect(calls[2].json).toEqual({ patch: "remote patch" })
expect(calls[3].json).toMatchObject({
directory: "remote-target-dir",
events: [
{
aggregateID: session.id,
seq: 0,
type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version),
},
{
aggregateID: session.id,
seq: historyNextSeq,
type: sessionUpdatedType(),
},
],
})
expect(calls[4].json).toEqual({ sessionID: session.id })
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
expect(sessionSequenceOwner(session.id)).toBe(target.id)
}),
{ git: true },
)
})
})
})
describe("workspace-old sync state", () => {
@@ -958,7 +1110,7 @@ describe("workspace-old sync state", () => {
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* sessionSvc.get(session.id)).title).toBe("from history")
expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from history")
}),
)
expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }])
@@ -1106,7 +1258,7 @@ describe("workspace-old sync state", () => {
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* sessionSvc.get(session.id)).title).toBe("from sse")
expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from sse")
}),
)
expect(
@@ -1215,313 +1367,3 @@ describe("workspace-old waitForSync", () => {
})
}, 7000)
})
describe("workspace-old sessionRestore", () => {
test("throws when the workspace is missing", async () => {
await withInstance(async () => {
await expect(
restoreWorkspaceSession({
workspaceID: WorkspaceID.ascending("wrk_restore_missing"),
sessionID: SessionID.descending("ses_restore_missing_workspace"),
}),
).rejects.toThrow("Workspace not found: wrk_restore_missing")
})
})
test("throws when switching a missing session fails", async () => {
await withInstance(async (dir) => {
const type = unique("restore-missing-session")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
await expect(
restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
).rejects.toThrow("NotFoundError")
await removeWorkspace(info.id)
})
})
it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
}
if (call.url.pathname === "/restore/sync/replay") {
replay.push(call)
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-remote")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(
Instance.project.id,
type,
remoteAdapter(`${url}/restore/?ignored=1#hash`, {
directory: dir,
headers: { authorization: "Bearer restore" },
}).adapter,
)
const session = yield* sessionSvc.create({ title: "restore remote" })
replaceSessionEvents(session.id, 24)
const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })
expect(result).toEqual({ total: 3 })
expect(replay).toHaveLength(3)
expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([
"/restore/sync/replay",
"/restore/sync/replay",
"/restore/sync/replay",
])
expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true)
expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true)
expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5])
expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir])
expect(
replay.flatMap((call) =>
(call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq),
),
).toEqual(Array.from({ length: 25 }, (_, i) => i))
expect(
(replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1),
).toMatchObject({
seq: 24,
type: sessionUpdatedType(),
data: { sessionID: session.id, info: { workspaceID: info.id } },
})
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("remote restore sends an empty directory string when the workspace directory is null", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("restore-null-dir")
const info = workspaceInfo(Instance.project.id, type, { directory: null })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter)
const session = yield* sessionSvc.create({ title: "null dir" })
replaceSessionEvents(session.id, 0)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 1,
})
expect((replay[0].json as { directory: string }).directory).toBe("")
expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1)
yield* workspace.remove(info.id)
}),
{ git: true },
)
})
})
it.live("remote restore failures include status and body and do not emit completed batch progress", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.text("replay failed", { status: 503 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-remote-fail")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter)
const session = yield* sessionSvc.create({ title: "restore fail" })
replaceSessionEvents(session.id, 11)
const error = yield* Effect.flip(
workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }),
)
expect((error as Error).message).toContain(
`Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`,
)
expect(replay).toHaveLength(1)
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("local restore replays batches and emits progress", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-local")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
const session = yield* sessionSvc.create({ title: "restore local" })
replaceSessionEvents(session.id, 20)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 3,
})
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
),
)
it.live("session restore includes real message and part events in sequence order", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("restore-real-events")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter)
const session = yield* sessionSvc.create({ title: "real events" })
for (let i = 0; i < 3; i++) {
const msg = yield* sessionSvc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
yield* sessionSvc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: msg.id,
type: "text",
text: `message ${i}`,
})
}
const before = eventRows(session.id)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 1,
})
const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events
expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1])
expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type))
expect(posted.at(-1)?.type).toBe(sessionUpdatedType())
yield* workspace.remove(info.id)
}),
{ git: true },
)
})
})
})
@@ -12,8 +12,14 @@ process.env.KILO_DISABLE_DEFAULT_PLUGINS = "1"
const { Flag } = await import("@opencode-ai/core/flag/flag")
const { Plugin } = await import("../../src/plugin/index")
const { Workspace } = await import("../../src/control-plane/workspace")
const { InstanceBootstrap } = await import("../../src/project/bootstrap")
const { Instance } = await import("../../src/project/instance")
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, Workspace.defaultLayer, CrossSpawnSpawner.defaultLayer))
const { InstanceStore } = await import("../../src/project/instance-store")
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer))
const experimental = Flag.KILO_EXPERIMENTAL_WORKSPACES
@@ -1,5 +1,6 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import { parsePatch } from "diff"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
@@ -288,6 +289,28 @@ describe("Vcs diff", () => {
})
})
test("diff('git') keeps carriage returns inside patch hunks", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8")
await withVcsOnly(tmp.path, async () => {
const diff = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff("git")
}),
)
const file = diff.find((item) => item.file === "file.txt")
expect(file?.patch).toContain(" same\rdiff --git inside")
expect(file?.patch).toContain("-delete")
expect(() => parsePatch(file?.patch ?? "")).not.toThrow()
})
}, 20_000)
test("diff('branch') returns changes against default branch", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()
@@ -0,0 +1,131 @@
// End-to-end regression test for opencode#24432.
//
// Routes through the actual ai-gateway-provider + @ai-sdk/openai-compatible
// chain that provider.ts:811 builds at runtime, with only the network boundary
// stubbed. Asserts that `reasoning_effort` (and other provider options the
// transform emits) actually land in the body Cloudflare AI Gateway forwards
// upstream, which is the only place the bug was observable.
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { JSONValue } from "ai"
import { generateText } from "ai"
import { createAiGateway } from "ai-gateway-provider"
import { createUnified } from "ai-gateway-provider/providers/unified"
import { ProviderTransform } from "@/provider/transform"
import type * as Provider from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
type Captured = { url: string; outerBody: unknown }
type ProviderOptions = Record<string, Record<string, JSONValue>>
const realFetch = globalThis.fetch
let captured: Captured | null = null
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
beforeEach(() => {
captured = null
const handle = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
if (url.startsWith("https://gateway.ai.cloudflare.com/")) {
const bodyText = typeof init?.body === "string" ? init.body : ""
captured = { url, outerBody: bodyText ? JSON.parse(bodyText) : null }
return new Response(
JSON.stringify({
id: "chatcmpl-test",
object: "chat.completion",
created: 0,
model: "openai/gpt-5.4",
choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}
return realFetch(input, init)
}
// `typeof fetch` includes Bun's `preconnect` method; preserve it from realFetch.
const stubFetch: typeof fetch = Object.assign(handle, { preconnect: realFetch.preconnect.bind(realFetch) })
globalThis.fetch = stubFetch
})
afterEach(() => {
globalThis.fetch = realFetch
})
const cfModel = (apiId: string, releaseDate = "2026-03-05"): Provider.Model => ({
id: ModelID.make(`cloudflare-ai-gateway/${apiId}`),
providerID: ProviderID.make("cloudflare-ai-gateway"),
name: apiId,
api: { id: apiId, url: "https://gateway.ai.cloudflare.com/v1/compat", npm: "ai-gateway-provider" },
capabilities: {
reasoning: true,
temperature: false,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 1, output: 1, cache: { read: 0, write: 0 } },
limit: { context: 1_000_000, output: 128_000 },
status: "active",
options: {},
headers: {},
release_date: releaseDate,
})
// ai-gateway-provider sends an array of step descriptors; each entry's `query`
// is the body forwarded to the upstream provider.
function extractUpstreamQuery(body: unknown): Record<string, unknown> | undefined {
if (!Array.isArray(body) || body.length === 0) return undefined
const first = body[0]
if (!isRecord(first)) return undefined
const query = first.query
return isRecord(query) ? query : undefined
}
async function callThroughGateway(apiId: string, providerOptions: ProviderOptions) {
const aigateway = createAiGateway({ accountId: "test", gateway: "test", apiKey: "test" })
const unified = createUnified()
await generateText({ model: aigateway(unified(apiId)), prompt: "hi", providerOptions })
return extractUpstreamQuery(captured?.outerBody)
}
describe("cf-ai-gateway end-to-end (regression: #24432)", () => {
test("ProviderTransform.providerOptions output puts reasoning_effort on the wire", async () => {
// The full chain the runtime exercises:
// transform.providerOptions() -> openaiCompatible key
// -> @ai-sdk/openai-compatible reads it as compatibleOptions
// -> emits body.reasoning_effort
// -> ai-gateway-provider wraps the body and forwards to gateway.ai.cloudflare.com
const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), { reasoningEffort: "xhigh" })
expect(opts).toEqual({ openaiCompatible: { reasoningEffort: "xhigh" } })
const upstream = await callThroughGateway("openai/gpt-5.4", opts)
expect(upstream?.reasoning_effort).toBe("xhigh")
})
test("variants() output for openai/gpt-5.4 lands xhigh on the wire", async () => {
// The other half of the bug: workflow `variant: xhigh` flows through variants()
// and must reach the wire. variants() returns the providerOptions payload
// unwrapped; providerOptions() wraps it under the SDK key.
const variants = ProviderTransform.variants(cfModel("openai/gpt-5.4"))
expect(variants.xhigh).toEqual({ reasoningEffort: "xhigh" })
const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), variants.xhigh)
const upstream = await callThroughGateway("openai/gpt-5.4", opts)
expect(upstream?.reasoning_effort).toBe("xhigh")
})
test("legacy buggy key 'cloudflare-ai-gateway' does NOT reach the wire (proves the bug)", async () => {
// Sanity: confirms the bug class. If a future change accidentally restores
// providerID-keyed providerOptions, this test fails before users notice.
const upstream = await callThroughGateway("openai/gpt-5.4", {
"cloudflare-ai-gateway": { reasoningEffort: "high" },
})
expect(upstream?.reasoning_effort).toBeUndefined()
})
})
@@ -1123,6 +1123,118 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
})
})
describe("ProviderTransform.message - surrogate sanitization", () => {
const model = {
id: "test/test-model",
providerID: "test",
api: {
id: "test-model",
url: "https://api.test.com",
npm: "@ai-sdk/openai-compatible",
},
name: "Test Model",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } },
limit: { context: 128000, output: 8192 },
status: "active",
options: {},
headers: {},
} as any
test("replaces lone surrogates in model-visible text", () => {
const lone = "\uD83D"
const valid = "🚀"
const sanitized = "�"
const text = (label: string) => `${label} ${lone} and ${valid}`
const expected = (label: string) => `${label} ${sanitized} and ${valid}`
const msgs = [
{ role: "system", content: text("system") },
{ role: "user", content: text("user string") },
{
role: "user",
content: [
{ type: "text", text: text("user text") },
{ type: "image", image: "data:image/png;base64,abcd" },
],
},
{ role: "assistant", content: text("assistant string") },
{
role: "assistant",
content: [
{ type: "text", text: text("assistant text") },
{ type: "reasoning", text: text("assistant reasoning") },
{ type: "tool-call", toolCallId: "call-1", toolName: "Read", input: { filePath: ".opencode/tool/emoji.ts" } },
{
type: "tool-result",
toolCallId: "call-2",
toolName: "Read",
output: { type: "text", value: text("assistant tool text") },
},
{
type: "tool-result",
toolCallId: "call-3",
toolName: "Read",
output: { type: "error-text", value: text("assistant tool error") },
},
{
type: "tool-result",
toolCallId: "call-4",
toolName: "Read",
output: { type: "content", value: [{ type: "text", text: text("assistant tool content") }] },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-5",
toolName: "Read",
output: { type: "text", value: text("tool text") },
},
{
type: "tool-result",
toolCallId: "call-6",
toolName: "Read",
output: { type: "error-text", value: text("tool error") },
},
{
type: "tool-result",
toolCallId: "call-7",
toolName: "Read",
output: { type: "content", value: [{ type: "text", text: text("tool content") }] },
},
],
},
] as any[]
const result = ProviderTransform.message(msgs, model, {}) as any[]
expect(result[0].content).toBe(expected("system"))
expect(result[1].content).toBe(expected("user string"))
expect(result[2].content[0].text).toBe(expected("user text"))
expect(result[3].content).toBe(expected("assistant string"))
expect(result[4].content[0].text).toBe(expected("assistant text"))
expect(result[4].content[1].text).toBe(expected("assistant reasoning"))
expect(result[4].content[3].output.value).toBe(expected("assistant tool text"))
expect(result[4].content[4].output.value).toBe(expected("assistant tool error"))
expect(result[4].content[5].output.value[0].text).toBe(expected("assistant tool content"))
expect(result[5].content[0].output.value).toBe(expected("tool text"))
expect(result[5].content[1].output.value).toBe(expected("tool error"))
expect(result[5].content[2].output.value[0].text).toBe(expected("tool content"))
expect(result[2].content[1]).toEqual({ type: "image", image: "data:image/png;base64,abcd" })
})
})
describe("ProviderTransform.message - empty image handling", () => {
const mockModel = {
id: "anthropic/claude-3-5-sonnet",
@@ -1993,7 +2105,7 @@ describe("ProviderTransform.message - bedrock caching with non-bedrock providerI
const msgs = [
{
role: "system",
content: [{ type: "text", text: "You are a helpful assistant" }],
content: "You are a helpful assistant",
},
{
role: "user",
@@ -2007,7 +2119,7 @@ describe("ProviderTransform.message - bedrock caching with non-bedrock providerI
expect(result[0].providerOptions?.bedrock).toEqual({
cachePoint: { type: "default" },
})
expect(result[0].content[0].providerOptions?.bedrock).toBeUndefined()
expect(result[0].content).toBe("You are a helpful assistant")
})
})
@@ -2044,7 +2156,7 @@ describe("ProviderTransform.message - cache control on gateway", () => {
const msgs = [
{
role: "system",
content: [{ type: "text", text: "You are a helpful assistant" }],
content: "You are a helpful assistant",
},
{
role: "user",
@@ -2054,7 +2166,7 @@ describe("ProviderTransform.message - cache control on gateway", () => {
const result = ProviderTransform.message(msgs, model, {}) as any[]
expect(result[0].content[0].providerOptions).toBeUndefined()
expect(result[0].content).toBe("You are a helpful assistant")
expect(result[0].providerOptions).toBeUndefined()
})
@@ -3077,6 +3189,36 @@ describe("ProviderTransform.variants", () => {
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
})
test("dotted gpt-5.x ids include 'minimal' (regression: matcher used to miss gpt-5.4)", () => {
const model = createMockModel({
id: "gpt-5.4",
providerID: "openai",
api: {
id: "gpt-5.4",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
release_date: "2026-03-05",
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
})
test("gpt-50 (lookalike) does not get gpt-5 family treatment", () => {
const model = createMockModel({
id: "gpt-50",
providerID: "openai",
api: {
id: "gpt-50",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
release_date: "2024-01-01",
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
})
})
describe("@ai-sdk/anthropic", () => {
@@ -3609,8 +3751,87 @@ describe("ProviderTransform.variants", () => {
expect(result).toEqual({})
})
})
// kilocode_change end
describe("ai-gateway-provider (cloudflare-ai-gateway)", () => {
const cfModel = (apiId: string, releaseDate = "2024-01-01") =>
createMockModel({
id: `cloudflare-ai-gateway/${apiId}`,
providerID: "cloudflare-ai-gateway",
api: {
id: apiId,
url: "https://gateway.ai.cloudflare.com/v1/compat",
npm: "ai-gateway-provider",
},
release_date: releaseDate,
})
test("openai gpt-5.4 includes xhigh effort (regression: variant=xhigh used to be silently ignored)", () => {
const result = ProviderTransform.variants(cfModel("openai/gpt-5.4", "2026-03-05"))
expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" })
expect(result.high).toEqual({ reasoningEffort: "high" })
expect(Object.keys(result)).toContain("minimal")
})
test("openai gpt-5.2-codex includes xhigh", () => {
const result = ProviderTransform.variants(cfModel("openai/gpt-5.2-codex", "2025-12-11"))
expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" })
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
})
test("openai gpt-4o (no reasoning) returns empty", () => {
const model = cfModel("openai/gpt-4o")
model.capabilities.reasoning = false
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
test("non-openai upstream falls back to widely-supported OAI efforts", () => {
const result = ProviderTransform.variants(cfModel("anthropic/claude-sonnet-4-6"))
expect(result).toEqual({
low: { reasoningEffort: "low" },
medium: { reasoningEffort: "medium" },
high: { reasoningEffort: "high" },
})
})
})
})
describe("ProviderTransform.providerOptions - ai-gateway-provider", () => {
const createModel = (overrides: Partial<any> = {}) =>
({
id: "cloudflare-ai-gateway/openai/gpt-5.4",
providerID: "cloudflare-ai-gateway",
api: {
id: "openai/gpt-5.4",
url: "https://gateway.ai.cloudflare.com/v1/compat",
npm: "ai-gateway-provider",
},
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 1, output: 1, cache: { read: 0, write: 0 } },
limit: { context: 1_000_000, output: 128_000 },
status: "active",
options: {},
headers: {},
release_date: "2026-03-05",
...overrides,
}) as any
test("routes options under openaiCompatible (the key @ai-sdk/openai-compatible reads)", () => {
// Regression: previously fell back to providerID="cloudflare-ai-gateway",
// which @ai-sdk/openai-compatible never reads, silently dropping reasoningEffort.
const result = ProviderTransform.providerOptions(createModel(), { reasoningEffort: "high" })
expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } })
})
})
// kilocode_change end
// kilocode_change start - tests for reasoningSummary guard
describe("ProviderTransform.options - OpenAI Responses API params guard", () => {
@@ -63,6 +63,19 @@ describe("HttpApi CORS", () => {
}),
)
it.live("adds CORS headers to legacy unauthorized responses", () =>
Effect.gen(function* () {
const response = yield* Effect.promise(async () =>
Server.Legacy().app.request("/global/config", {
headers: { origin: "https://app.opencode.ai" },
}),
)
expect(response.status).toBe(401)
expect(response.headers.get("access-control-allow-origin")).toBe("https://app.opencode.ai")
}),
)
it.live("uses custom CORS origins passed to the server", () =>
Effect.gen(function* () {
const listener = yield* Effect.acquireRelease(
@@ -13,8 +13,8 @@ import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
// kilocode_change start - avoid InstanceLayer.layer's lazy bootstrap import in this test graph.
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
// kilocode_change end
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
@@ -39,6 +39,11 @@ const testStateLayer = Layer.effectDiscard(
}),
)
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
@@ -48,7 +53,7 @@ const it = testEffect(
InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)),
// kilocode_change end
Project.defaultLayer,
Workspace.defaultLayer,
workspaceLayer,
),
)
@@ -105,23 +105,22 @@ describe("404 mapping for missing session", () => {
})
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 3: 404 response body shape should match Hono's NamedError
// envelope `{ name, data: { message } }`. HttpApi returns the typed-error
// shape `{ _tag }` instead. SDK consumers reading `error.data.message`
// see undefined.
//
// FIXME: unskip when error JSON shape policy is decided + applied (separate PR).
// Reproducer 3: 404 response body shape should match Hono's public NamedError
// envelope `{ name, data: { message } }`. SDK consumers read
// `error.data.message`, so returning an Effect built-in `{ _tag }` body is a
// compatibility break.
// ──────────────────────────────────────────────────────────────────────────────
describe("Error JSON shape parity", () => {
test.todo("HttpApi 404 body matches NamedError shape", async () => {
test("HttpApi 404 body matches Hono shape", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const headers = { "x-kilo-directory": tmp.path }
const response = await app(true).request("/session/ses_does_not_exist", {
headers: { "x-kilo-directory": tmp.path },
})
const hono = await app(false).request("/session/ses_does_not_exist", { headers })
const httpapi = await app(true).request("/session/ses_does_not_exist", { headers })
expect(response.status).toBe(404)
const body = (await response.json()) as { name?: string; data?: { message?: string } }
expect(httpapi.status).toBe(hono.status)
const body = (await httpapi.json()) as { name?: string; data?: { message?: string } }
expect(body).toEqual(await hono.json())
expect(body.name).toBe("NotFoundError")
expect(typeof body.data?.message).toBe("string")
})
@@ -50,9 +50,9 @@ const effectIt = testEffect(
),
)
function app() {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
function app(experimental = true) {
Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
function serverUrl() {
@@ -121,6 +121,18 @@ describe("pty HttpApi bridge", () => {
expect(missing.status).toBe(404)
})
test("matches Hono missing PTY error body", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-kilo-directory": tmp.path }
const path = PtyPaths.get.replace(":ptyID", PtyID.ascending())
const hono = await app(false).request(path, { headers })
const httpapi = await app().request(path, { headers })
expect(httpapi.status).toBe(hono.status)
expect(await httpapi.json()).toEqual(await hono.json())
})
test("returns 404 for missing PTY websocket before upgrade", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), {
@@ -4,6 +4,7 @@ import type * as Scope from "effect/Scope"
import { HttpRouter } from "effect/unstable/http"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createKiloClient } from "@kilocode/sdk/v2"
import { validateSession } from "../../src/cli/cmd/tui/validate-session"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
@@ -13,6 +14,7 @@ import { MessageV2 } from "../../src/session/message-v2"
import { ModelID, ProviderID } from "../../src/provider/schema"
import type { Config } from "@/config/config"
import { Session as SessionNs } from "@/session/session"
import { errorMessage } from "../../src/util/error"
import { TestLLMServer } from "../lib/llm-server"
import path from "path"
import { resetDatabase } from "../fixture/db"
@@ -64,20 +66,23 @@ function client(
directory?: string,
input?: { password?: string; username?: string; headers?: Record<string, string> },
) {
const serverApp = app(backend, input)
const fetch = Object.assign(
async (request: RequestInfo | URL, init?: RequestInit) =>
await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
{ preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch
return createKiloClient({
baseUrl: "http://localhost",
directory,
headers: input?.headers,
fetch,
fetch: serverFetch(backend, input),
})
}
function serverFetch(backend: Backend, input?: { password?: string; username?: string }) {
const serverApp = app(backend, input)
return Object.assign(
async (request: RequestInfo | URL, init?: RequestInit) =>
await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
{ preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch
}
function authorization(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
@@ -129,6 +134,16 @@ function capture(request: () => Promise<SdkResult>) {
)
}
function captureThrown(request: () => Promise<unknown>) {
return call(async () => {
try {
await request()
} catch (error) {
return error
}
})
}
function expectStatus(request: () => Promise<{ response: Response }>, status: number) {
return call(request).pipe(
Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))),
@@ -355,6 +370,46 @@ describe("HttpApi SDK", () => {
),
)
parity("matches generated SDK missing session errors across backends", (backend) =>
withStandardProject(backend, ({ sdk }) =>
Effect.gen(function* () {
const sessionID = "ses_missing"
const expected = {
name: "NotFoundError",
data: { message: `Session not found: ${sessionID}` },
}
const missing = yield* capture(() => sdk.session.get({ sessionID }))
const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
expect(missing.error).toEqual(expected)
expect(thrown).toEqual(expected)
return {
status: missing.status,
error: missing.error,
thrown,
}
}),
),
)
parity("formats missing session validation errors for -s", (backend) =>
withStandardProject(backend, ({ directory }) =>
Effect.gen(function* () {
const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
const thrown = yield* captureThrown(() =>
validateSession({
url: "http://localhost",
directory,
sessionID,
fetch: serverFetch(backend),
}),
)
expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
return errorMessage(thrown)
}),
),
)
parity("matches generated SDK basic auth behavior across backends", (backend) =>
withStandardProject(backend, ({ directory }) =>
Effect.gen(function* () {
@@ -1,24 +1,26 @@
import { afterEach, describe, expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import { Database } from "@/storage/db"
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
import { SessionMessage } from "../../src/v2/session-message"
import { Modelv2 } from "../../src/v2/model"
import * as DateTime from "effect/DateTime"
import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm"
@@ -30,6 +32,10 @@ void Log.init({ print: false })
const original = Flag.KILO_EXPERIMENTAL_HTTPAPI
const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
function app(experimental = true) {
Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental
@@ -54,7 +60,7 @@ function createSession(directory: string, input?: Session.CreateInput) {
)
}
function createTextMessage(directory: string, sessionID: SessionID, text: string) {
function createTextMessage(directory: string, sessionID: SessionIDType, text: string) {
return Effect.promise(
async () =>
await WithInstance.provide({
@@ -106,7 +112,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
extra: null,
projectID: input.projectID,
}),
).pipe(Effect.provide(Workspace.defaultLayer))
).pipe(Effect.provide(workspaceLayer))
})
function request(path: string, init?: RequestInit) {
@@ -124,6 +130,10 @@ function json<T>(response: Response) {
})
}
function responseJson(response: Response) {
return Effect.promise(() => response.json())
}
function requestJson<T>(path: string, init?: RequestInit) {
return request(path, init).pipe(Effect.flatMap(json<T>))
}
@@ -146,6 +156,47 @@ afterEach(async () => {
})
describe("session HttpApi", () => {
it.live(
"returns declared not found errors for read routes",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-kilo-directory": tmp.path }
const missingSession = SessionID.descending()
const missingSessionBody = {
name: "NotFoundError",
data: { message: `Session not found: ${missingSession}` },
}
const get = yield* request(pathFor(SessionPaths.get, { sessionID: missingSession }), { headers })
expect(get.status).toBe(404)
expect(yield* responseJson(get)).toEqual(missingSessionBody)
const messages = yield* request(pathFor(SessionPaths.messages, { sessionID: missingSession }), { headers })
expect(messages.status).toBe(404)
expect(yield* responseJson(messages)).toEqual(missingSessionBody)
const remove = yield* request(pathFor(SessionPaths.remove, { sessionID: missingSession }), {
headers,
method: "DELETE",
})
expect(remove.status).toBe(404)
expect(yield* responseJson(remove)).toEqual(missingSessionBody)
const session = yield* createSession(tmp.path, { title: "missing message" })
const missingMessage = MessageID.ascending()
const message = yield* request(
pathFor(SessionPaths.message, { sessionID: session.id, messageID: missingMessage }),
{ headers },
)
expect(message.status).toBe(404)
expect(yield* responseJson(message)).toEqual({
name: "NotFoundError",
data: { message: `Message not found: ${missingMessage}` },
})
}),
),
)
it.live(
"serves read routes through Hono bridge",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
@@ -214,7 +265,11 @@ describe("session HttpApi", () => {
id: SessionMessage.ID.create(),
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
},
time: { created: DateTime.makeUnsafe(1) },
content: [],
})
@@ -72,14 +72,27 @@ describe("tui HttpApi bridge", () => {
properties: { text: "from publish" },
})
const missingSessionID = SessionID.descending()
const missing = await app().request(TuiPaths.selectSession, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ sessionID: SessionID.descending() }),
body: JSON.stringify({ sessionID: missingSessionID }),
})
expect(missing.status).toBe(404)
})
test("matches Hono missing selected session error body", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-kilo-directory": tmp.path, "content-type": "application/json" }
const body = JSON.stringify({ sessionID: SessionID.descending() })
const hono = await app(false).request(TuiPaths.selectSession, { method: "POST", headers, body })
const httpapi = await app().request(TuiPaths.selectSession, { method: "POST", headers, body })
expect(httpapi.status).toBe(hono.status)
expect(await httpapi.json()).toEqual(await hono.json())
})
test("matches legacy unknown execute command behavior", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-kilo-directory": tmp.path, "content-type": "application/json" }
@@ -20,6 +20,8 @@ import { WorkspaceID } from "../../src/control-plane/schema"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import {
@@ -45,13 +47,18 @@ const testStateLayer = Layer.effectDiscard(
}),
)
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
Project.defaultLayer,
Workspace.defaultLayer,
workspaceLayer,
Socket.layerWebSocketConstructorGlobal,
),
)
@@ -14,6 +14,8 @@ import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { WorkspaceRef } from "../../src/effect/instance-ref"
@@ -23,9 +25,11 @@ void Log.init({ print: false })
const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES
const originalHttpApi = Flag.KILO_EXPERIMENTAL_HTTPAPI
const it = testEffect(
Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer),
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) {
return Effect.promise(() => {
@@ -168,22 +172,19 @@ describe("workspace HttpApi", () => {
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-test", branch: null, extra: null }),
body: JSON.stringify({ type: "local-test", branch: null }),
})
expect(created.status).toBe(200)
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir))
const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, {
const warped = yield* request(WorkspacePaths.warp, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionID: session.id }),
})
expect(restored.status).toBe(200)
expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({
total: expect.any(Number),
body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
})
expect(warped.status).toBe(204)
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
expect(removed.status).toBe(200)
@@ -212,7 +213,6 @@ describe("workspace HttpApi", () => {
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
type: "local-test",
name: "local-test",
extra: null,
})
}),
)
@@ -257,7 +257,6 @@ describe("workspace HttpApi", () => {
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
type: "local-test",
name: "local-test",
extra: null,
})
}),
)
@@ -272,7 +271,7 @@ describe("workspace HttpApi", () => {
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-target", branch: null, extra: null }),
body: JSON.stringify({ type: "local-target", branch: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
@@ -327,7 +326,7 @@ describe("workspace HttpApi", () => {
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-target", branch: null, extra: null }),
body: JSON.stringify({ type: "remote-target", branch: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
@@ -394,7 +393,7 @@ describe("workspace HttpApi", () => {
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }),
body: JSON.stringify({ type: "remote-session-target", branch: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
const session = yield* Session.Service.use((svc) => svc.create()).pipe(
@@ -1219,7 +1219,9 @@ describe("session.compaction.process", () => {
expect(captured).not.toContain("keep tail")
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(filtered[0]?.info.id).toBe(keep.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()
@@ -1098,6 +1098,108 @@ describe("session.message-v2.toModelMessage", () => {
},
])
})
test("substitutes space for empty text between signed reasoning blocks", async () => {
// Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)]
const assistantID = "m-assistant"
const input: MessageV2.WithParts[] = [
{
info: assistantInfo(assistantID, "m-parent"),
parts: [
{ ...basePart(assistantID, "p1"), type: "step-start" },
{
...basePart(assistantID, "p2"),
type: "reasoning",
text: "thinking-one",
metadata: { anthropic: { signature: "sig1" } },
},
{ ...basePart(assistantID, "p3"), type: "text", text: "" },
{ ...basePart(assistantID, "p4"), type: "step-start" },
{
...basePart(assistantID, "p5"),
type: "reasoning",
text: "thinking-two",
metadata: { anthropic: { signature: "sig2" } },
},
{ ...basePart(assistantID, "p6"), type: "text", text: "the answer" },
] as MessageV2.Part[],
},
]
const result = await MessageV2.toModelMessages(input, model)
// step-start splits into two assistant messages; SDK's groupIntoBlocks merges them later
expect(result).toHaveLength(2)
expect((result[0].content as any[]).find((p) => p.type === "text").text).toBe(" ")
expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer")
})
test("substitutes space for empty text when reasoning signature is under 'bedrock' namespace", async () => {
// AWS Bedrock hosts Anthropic Claude but stores signatures under metadata.bedrock
const assistantID = "m-assistant-bedrock"
const input: MessageV2.WithParts[] = [
{
info: assistantInfo(assistantID, "m-parent"),
parts: [
{
...basePart(assistantID, "p1"),
type: "reasoning",
text: "thinking-bedrock",
metadata: { bedrock: { signature: "bedrock-sig" } },
},
{ ...basePart(assistantID, "p2"), type: "text", text: "" },
{ ...basePart(assistantID, "p3"), type: "text", text: "answer" },
] as MessageV2.Part[],
},
]
const result = await MessageV2.toModelMessages(input, model)
expect(result).toHaveLength(1)
const texts = (result[0].content as any[]).filter((p) => p.type === "text")
expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"])
})
test("leaves empty text alone when reasoning has no Anthropic signature", async () => {
// Non-Anthropic providers' reasoning doesn't position-validate, so empty text
// should be filtered normally rather than substituted.
const assistantID = "m-assistant-unsigned"
const input: MessageV2.WithParts[] = [
{
info: assistantInfo(assistantID, "m-parent"),
parts: [
{ ...basePart(assistantID, "p1"), type: "reasoning", text: "thinking" },
{ ...basePart(assistantID, "p2"), type: "text", text: "" },
{ ...basePart(assistantID, "p3"), type: "text", text: "answer" },
] as MessageV2.Part[],
},
]
const result = await MessageV2.toModelMessages(input, model)
expect(result).toHaveLength(1)
const texts = (result[0].content as any[]).filter((p) => p.type === "text")
expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"])
})
test("leaves empty text alone in assistant messages without reasoning", async () => {
const assistantID = "m-assistant-no-reasoning"
const input: MessageV2.WithParts[] = [
{
info: assistantInfo(assistantID, "m-parent"),
parts: [
{ ...basePart(assistantID, "p1"), type: "text", text: "" },
{ ...basePart(assistantID, "p2"), type: "text", text: "hello" },
] as MessageV2.Part[],
},
]
const result = await MessageV2.toModelMessages(input, model)
expect(result).toHaveLength(1)
const texts = (result[0].content as any[]).filter((p) => p.type === "text")
expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"])
})
})
describe("session.message-v2.fromError", () => {
@@ -834,7 +834,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result.map((item) => item.info.id)).toEqual([u2, a2, c1, s1, u3, a3])
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3])
await svc.remove(session.id)
},
@@ -889,7 +889,7 @@ describe("MessageV2.filterCompacted", () => {
})
const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(parentFiltered.map((item) => item.info.id)).toEqual([u2, a2, c1, s1, u3, a3])
expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3])
const forked = await svc.fork({ sessionID: session.id })
const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id))
@@ -964,7 +964,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result.map((item) => item.info.id)).toEqual([a3, c1, s1, u3, a4])
expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4])
await svc.remove(session.id)
},
@@ -1041,7 +1041,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result.map((item) => item.info.id)).toEqual([u3, a3, c2, s2, u4, a4])
expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4])
await svc.remove(session.id)
},
@@ -946,6 +946,43 @@ it.live(
)
// kilocode_change end
it.live(
"cancel propagates from slash command subtask to child session",
() =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const status = yield* SessionStatus.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* llm.hang
const msg = yield* user(chat.id, "hello")
yield* addSubtask(chat.id, msg.id)
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* llm.wait(1)
const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
const tool = taskMsg ? toolPart(taskMsg.parts) : undefined
const sessionID = tool?.state.status === "running" ? tool.state.metadata?.sessionId : undefined
expect(typeof sessionID).toBe("string")
if (typeof sessionID !== "string") throw new Error("missing child session id")
const childID = SessionID.make(sessionID)
expect((yield* status.get(childID)).type).toBe("busy")
yield* prompt.cancel(chat.id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
expect((yield* status.get(chat.id)).type).toBe("idle")
expect((yield* status.get(childID)).type).toBe("idle")
}),
{ git: true, config: providerCfg },
),
10_000,
)
it.live(
"cancel with queued callers resolves all cleanly",
() =>
+72 -1
View File
@@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../src/bus"
import { SyncEvent } from "../../src/sync"
import { Database } from "@/storage/db"
import { EventTable } from "../../src/sync/event.sql"
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
import { MessageID } from "../../src/session/schema"
import { Flag } from "@opencode-ai/core/flag/flag"
import { initProjectors } from "../../src/server/projectors"
@@ -252,5 +252,76 @@ describe("SyncEvent", () => {
}),
),
)
it.live(
"claims unowned event sequence on replay with ownerID",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
yield* SyncEvent.use.replay(
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "owned" },
},
{ publish: false, ownerID: "owner-1" },
)
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.get(),
)
expect(row).toEqual({ seq: 0, ownerID: "owner-1" })
}),
),
)
it.live(
"ignores replay from a different owner after sequence is claimed",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
yield* SyncEvent.use.replay(
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "first" },
},
{ publish: false, ownerID: "owner-1" },
)
yield* SyncEvent.use.replay(
{
id: "evt_2",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 1,
aggregateID: id,
data: { id, name: "ignored" },
},
{ publish: false, ownerID: "owner-2" },
)
const events = Database.use((db) => db.select().from(EventTable).all())
const sequence = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.get(),
)
expect(events).toHaveLength(1)
expect(events[0].id).toBe("evt_1")
expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
}),
),
)
})
})
+291 -242
View File
@@ -1,9 +1,8 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { Config } from "@/config/config"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Instance } from "../../src/project/instance"
import { Session } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import type { SessionPrompt } from "../../src/session/prompt"
@@ -12,7 +11,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
@@ -35,6 +34,14 @@ const it = testEffect(
),
)
function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
const session = yield* Session.Service
const chat = yield* session.create({ title })
@@ -72,7 +79,7 @@ function stubOps(opts?: {
childCost?: number
}): TaskPromptOps {
return {
cancel() {},
cancel: () => Effect.void,
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) =>
Effect.gen(function* () {
@@ -118,102 +125,271 @@ function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithPa
}
describe("tool.task", () => {
it.live("description sorts subagents by name and is stable across calls", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const registry = yield* ToolRegistry.Service
const get = Effect.fnUntraced(function* () {
const tools = yield* registry.tools({ ...ref, agent: build })
return tools.find((tool) => tool.id === TaskTool.id)?.description ?? ""
})
const first = yield* get()
const second = yield* get()
it.instance(
"description sorts subagents by name and is stable across calls",
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const registry = yield* ToolRegistry.Service
const get = Effect.fnUntraced(function* () {
const tools = yield* registry.tools({ ...ref, agent: build })
return tools.find((tool) => tool.id === TaskTool.id)?.description ?? ""
})
const first = yield* get()
const second = yield* get()
expect(first).toBe(second)
expect(first).toBe(second)
const alpha = first.indexOf("- alpha: Alpha agent")
const explore = first.indexOf("- explore:")
const general = first.indexOf("- general:")
const zebra = first.indexOf("- zebra: Zebra agent")
const alpha = first.indexOf("- alpha: Alpha agent")
const explore = first.indexOf("- explore:")
const general = first.indexOf("- general:")
const zebra = first.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)
expect(general).toBeGreaterThan(explore)
expect(zebra).toBeGreaterThan(general)
}),
{
config: {
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)
expect(general).toBeGreaterThan(explore)
expect(zebra).toBeGreaterThan(general)
}),
{
config: {
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
},
},
),
},
)
it.live("description hides denied subagents for the caller", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const registry = yield* ToolRegistry.Service
const description =
(yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? ""
it.instance(
"description hides denied subagents for the caller",
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const registry = yield* ToolRegistry.Service
const description =
(yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? ""
expect(description).toContain("- alpha: Alpha agent")
expect(description).not.toContain("- zebra: Zebra agent")
}),
{
config: {
permission: {
task: {
"*": "allow",
zebra: "deny",
},
expect(description).toContain("- alpha: Alpha agent")
expect(description).not.toContain("- zebra: Zebra agent")
}),
{
config: {
permission: {
task: {
"*": "allow",
zebra: "deny",
},
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
},
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
},
},
),
},
)
it.live("execute resumes an existing task session from task_id", () =>
provideTmpdirInstance(() =>
it.instance("execute resumes an existing task session from task_id", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: child.id,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(child.id)
expect(result.metadata.sessionId).toBe(child.id)
expect(result.output).toContain(`task_id: ${child.id}`)
expect(seen?.sessionID).toBe(child.id)
}),
)
it.instance("execute asks by default and skips checks when bypassed", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const calls: unknown[] = []
const promptOps = stubOps()
const exec = (extra?: Record<string, any>) =>
def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps, ...extra },
messages: [],
metadata: () => Effect.void,
ask: (input) =>
Effect.sync(() => {
calls.push(input)
}),
},
)
yield* exec()
yield* exec({ bypassAgentCheck: true })
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual({
permission: "task",
patterns: ["general"],
always: ["*"],
metadata: {
description: "inspect bug",
subagent_type: "general",
},
})
}),
)
it.instance("execute cancels child session when abort signal fires", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const ready = defer<SessionPrompt.PromptInput>()
const cancelled = defer<SessionID>()
const abort = new AbortController()
const promptOps: TaskPromptOps = {
cancel: (sessionID) =>
Effect.sync(() => {
cancelled.resolve(sessionID)
}),
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) =>
Effect.promise(() => {
ready.resolve(input)
return cancelled.promise
}).pipe(Effect.as(reply(input, "cancelled"))),
}
const fiber = yield* def
.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: abort.signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
.pipe(Effect.forkChild)
const input = yield* Effect.promise(() => ready.promise)
abort.abort()
expect(yield* Effect.promise(() => cancelled.promise)).toBe(input.sessionID)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
}),
)
it.instance("execute creates a child when task_id does not exist", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: "ses_missing",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(result.metadata.sessionId)
expect(result.metadata.sessionId).not.toBe("ses_missing")
expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`)
expect(seen?.sessionID).toBe(result.metadata.sessionId)
}),
)
it.instance(
"execute shapes child permissions for task, todowrite, and primary tools",
() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: child.id,
subagent_type: "reviewer",
},
{
sessionID: chat.id,
@@ -227,184 +403,57 @@ describe("tool.task", () => {
},
)
const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(child.id)
expect(result.metadata.sessionId).toBe(child.id)
expect(result.output).toContain(`task_id: ${child.id}`)
expect(seen?.sessionID).toBe(child.id)
}),
),
)
it.live("execute asks by default and skips checks when bypassed", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const calls: unknown[] = []
const promptOps = stubOps()
const exec = (extra?: Record<string, any>) =>
def.execute(
const child = yield* sessions.get(result.metadata.sessionId)
expect(child.parentID).toBe(chat.id)
// kilocode_change start — use arrayContaining: Kilo appends inherited caller restrictions
expect(child.permission).toEqual(
expect.arrayContaining([
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
permission: "todowrite",
pattern: "*",
action: "deny",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps, ...extra },
messages: [],
metadata: () => Effect.void,
ask: (input) =>
Effect.sync(() => {
calls.push(input)
}),
permission: "bash",
pattern: "*",
action: "allow",
},
)
yield* exec()
yield* exec({ bypassAgentCheck: true })
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual({
permission: "task",
patterns: ["general"],
always: ["*"],
metadata: {
description: "inspect bug",
subagent_type: "general",
},
{
permission: "read",
pattern: "*",
action: "allow",
},
{
permission: "task",
pattern: "*",
action: "deny",
},
]),
)
// kilocode_change end
expect(seen?.tools).toEqual({
todowrite: false,
task: false, // kilocode_change - Kilo disallows nested subagents
bash: false,
read: false,
})
}),
),
)
it.live("execute creates a child when task_id does not exist", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: "ses_missing",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(result.metadata.sessionId)
expect(result.metadata.sessionId).not.toBe("ses_missing")
expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`)
expect(seen?.sessionID).toBe(result.metadata.sessionId)
}),
),
)
it.live("execute shapes child permissions for task, todowrite, and primary tools", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "reviewer",
{
config: {
agent: {
reviewer: {
mode: "subagent",
permission: {
task: "allow",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const child = yield* sessions.get(result.metadata.sessionId)
expect(child.parentID).toBe(chat.id)
// kilocode_change start — use arrayContaining: Kilo appends inherited caller restrictions
expect(child.permission).toEqual(
expect.arrayContaining([
{
permission: "todowrite",
pattern: "*",
action: "deny",
},
{
permission: "bash",
pattern: "*",
action: "allow",
},
{
permission: "read",
pattern: "*",
action: "allow",
},
{
permission: "task",
pattern: "*",
action: "deny",
},
]),
)
// kilocode_change end
expect(seen?.tools).toEqual({
todowrite: false,
task: false, // kilocode_change - Kilo disallows nested subagents
bash: false,
read: false,
})
}),
{
config: {
agent: {
reviewer: {
mode: "subagent",
permission: {
task: "allow",
},
},
},
experimental: {
primary_tools: ["bash", "read"],
openTelemetry: true, // kilocode_change
},
},
experimental: {
primary_tools: ["bash", "read"],
openTelemetry: true, // kilocode_change
},
},
),
},
)
})
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
import * as DateTime from "effect/DateTime"
import { SessionID } from "../../src/session/schema"
import { EventV2 } from "../../src/v2/event"
import { Modelv2 } from "../../src/v2/model"
import { SessionEvent } from "../../src/v2/session-event"
import { SessionMessageUpdater } from "../../src/v2/session-message-updater"
@@ -16,7 +17,11 @@ test("step snapshots carry over to assistant messages", () => {
sessionID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: { id: "model", providerID: "provider" },
model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
},
snapshot: "before",
},
} satisfies SessionEvent.Event)
@@ -56,7 +61,11 @@ test("text ended populates assistant text content", () => {
sessionID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: { id: "model", providerID: "provider" },
model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
},
},
} satisfies SessionEvent.Event)
@@ -96,7 +105,11 @@ test("tool completion stores completed timestamp", () => {
sessionID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: { id: "model", providerID: "provider" },
model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
},
},
} satisfies SessionEvent.Event)