mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
resolve merge conflicts
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE `event_sequence` ADD `owner_id` text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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: {},
|
||||
}))
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ export const layer = Layer.effect(
|
||||
cwd: dir,
|
||||
env: item.environment,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ const prefixes = {
|
||||
tool: "tool",
|
||||
workspace: "wrk",
|
||||
entry: "ent",
|
||||
account: "act",
|
||||
} as const
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
+5
-1
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
() =>
|
||||
|
||||
@@ -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" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user