mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
resolve merge conflicts
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
v1.17.5
|
||||
v1.17.9
|
||||
|
||||
@@ -52,6 +52,9 @@ _Avoid_: Request body, wire options
|
||||
**Generation Controls**:
|
||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||
|
||||
**PTY Environment**:
|
||||
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||
@@ -99,6 +102,8 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
|
||||
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `KILO_TERMINAL`.
|
||||
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
||||
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
- Ambient project instruction discovery honors `KILO_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
exact = true
|
||||
# Keep Kilo's longer supply-chain quarantine while allowing packages that must track coordinated releases.
|
||||
minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours)
|
||||
minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||
minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
+13
-7
@@ -7,7 +7,6 @@
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:local": "bun run packages/opencode/script/dev-local.ts",
|
||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
@@ -19,7 +18,8 @@
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"test": "echo 'do not run tests from root' && exit 1",
|
||||
"extension": "bun --cwd packages/kilo-vscode script/launch.ts",
|
||||
"dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup"
|
||||
"dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup",
|
||||
"dev:local": "bun run packages/opencode/script/dev-local.ts"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
@@ -45,7 +45,7 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.1.22",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"opentui-spinner": "0.0.7",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
@@ -70,7 +70,7 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20260316.1",
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"shiki": "3.20.0",
|
||||
"shiki": "4.2.0",
|
||||
"solid-list": "0.3.0",
|
||||
"tailwindcss": "4.1.11",
|
||||
"virtua": "0.49.1",
|
||||
@@ -85,7 +85,9 @@
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"sst": "4.13.1"
|
||||
"sst": "4.13.1",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@shikijs/stream": "4.2.0"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -157,11 +159,15 @@
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
|
||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
|
||||
},
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"migration": "bun run script/migration.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
|
||||
},
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode"
|
||||
|
||||
@@ -10,8 +10,7 @@ import { Location } from "./location"
|
||||
import { EventV2 } from "./event"
|
||||
import { Policy } from "./policy"
|
||||
import { State } from "./state"
|
||||
import { Credential } from "./credential"
|
||||
import { IntegrationSchema } from "./integration/schema"
|
||||
import { Integration } from "./integration"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
@@ -35,12 +34,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
|
||||
export const PolicyActions = Schema.Literals(["provider.use"])
|
||||
|
||||
export const Event = {
|
||||
ModelUpdated: EventV2.define({
|
||||
type: "catalog.model.updated",
|
||||
schema: {
|
||||
model: ModelV2.Info,
|
||||
},
|
||||
}),
|
||||
Updated: EventV2.define({ type: "catalog.updated", schema: {} }),
|
||||
}
|
||||
|
||||
type Data = {
|
||||
@@ -96,31 +90,20 @@ export const layer = Layer.effect(
|
||||
const plugin = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const policy = yield* Policy.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const project = (provider: ProviderV2.Info, active: Map<IntegrationSchema.ID, Credential.Stored>) => {
|
||||
const credential = active.get(IntegrationSchema.ID.make(provider.id))
|
||||
if (!credential) return provider
|
||||
const body = { ...provider.request.body }
|
||||
if (credential.value.type === "key") {
|
||||
body.apiKey = credential.value.key
|
||||
Object.assign(body, credential.value.metadata ?? {})
|
||||
}
|
||||
// kilocode_change start - preserve Kilo organization routing from migrated OAuth credentials
|
||||
if (credential.value.type === "oauth") {
|
||||
body.apiKey = credential.value.access
|
||||
if (credential.value.metadata?.accountID) body.kilocodeOrganizationId = credential.value.metadata.accountID
|
||||
}
|
||||
// kilocode_change end
|
||||
return new ProviderV2.Info({
|
||||
...provider,
|
||||
enabled: { via: "credential", credentialID: credential.id },
|
||||
request: { ...provider.request, body },
|
||||
})
|
||||
// kilocode_change start - upstream moved credential projection into SessionRunnerModel; the Kilo OAuth
|
||||
// accountID -> kilocodeOrganizationId mapping is ported there, while this helper only filters availability.
|
||||
// kilocode_change end
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.request.body.apiKey === "string") return true
|
||||
if (connected) return true
|
||||
return !integration
|
||||
}
|
||||
|
||||
const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
const api =
|
||||
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
|
||||
? { ...provider.api, id: model.api.id }
|
||||
@@ -208,18 +191,16 @@ export const layer = Layer.effect(
|
||||
},
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
|
||||
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
|
||||
if (!policy.hasStatements()) return
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
catalog.provider.remove(record.provider.id)
|
||||
if (policy.hasStatements()) {
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
catalog.provider.remove(record.provider.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
const active = Effect.fn("CatalogV2.active")(function* () {
|
||||
return new Map((yield* credentials.all()).map((credential) => [credential.integrationID, credential]))
|
||||
})
|
||||
|
||||
yield* events.subscribe(PluginV2.Event.Added).pipe(
|
||||
// Plugin registries are location scoped even though the event bus is process scoped.
|
||||
Stream.filter(
|
||||
@@ -238,18 +219,23 @@ export const layer = Layer.effect(
|
||||
provider: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
return project(record.provider, yield* active())
|
||||
return record.provider
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
||||
const credentials = yield* active()
|
||||
return Array.fromIterable(state.get().providers.values()).map((record) =>
|
||||
project(record.provider, credentials),
|
||||
)
|
||||
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
return (yield* result.provider.all()).filter((provider) => provider.enabled)
|
||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
const connections = yield* integrations.connection.list()
|
||||
return (yield* result.provider.all()).filter((provider) =>
|
||||
available(
|
||||
provider,
|
||||
active.get(Integration.ID.make(provider.id)),
|
||||
connections.has(Integration.ID.make(provider.id)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -258,33 +244,32 @@ export const layer = Layer.effect(
|
||||
const record = yield* getRecord(providerID)
|
||||
const model = record.models.get(modelID)
|
||||
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
|
||||
return resolve(model, project(record.provider, yield* active()))
|
||||
return projectModel(model, record.provider)
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.model.all")(function* () {
|
||||
const credentials = yield* active()
|
||||
return pipe(
|
||||
Array.fromIterable(state.get().providers.values()),
|
||||
Array.flatMap((record) => {
|
||||
const provider = project(record.provider, credentials)
|
||||
return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider))
|
||||
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
|
||||
}),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.model.available")(function* () {
|
||||
const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider]))
|
||||
return (yield* result.model.all()).filter(
|
||||
(model) => providers.get(model.providerID)?.enabled !== false && model.enabled,
|
||||
)
|
||||
const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
|
||||
return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled)
|
||||
}),
|
||||
|
||||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||
const defaultModel = state.get().defaultModel
|
||||
if (defaultModel) {
|
||||
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
|
||||
if (Option.isSome(provider) && provider.value.enabled !== false) {
|
||||
if (
|
||||
Option.isSome(provider) &&
|
||||
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
|
||||
) {
|
||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
|
||||
if (Option.isSome(model) && model.value.enabled) return model
|
||||
}
|
||||
@@ -300,11 +285,11 @@ export const layer = Layer.effect(
|
||||
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return Option.none<ModelV2.Info>()
|
||||
const provider = project(record.provider, yield* active())
|
||||
const provider = record.provider
|
||||
|
||||
if (providerID === ProviderV2.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
|
||||
}
|
||||
|
||||
const candidates = pipe(
|
||||
@@ -332,7 +317,7 @@ export const layer = Layer.effect(
|
||||
return pipe(
|
||||
items,
|
||||
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
|
||||
Array.map((item) => resolve(item.model, provider)),
|
||||
Array.map((item) => projectModel(item.model, provider)),
|
||||
Array.head,
|
||||
)
|
||||
}
|
||||
@@ -353,6 +338,7 @@ export const layer = Layer.effect(
|
||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Policy.locationLayer),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as ConfigProviderPlugin from "./provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Config } from "../../config"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
@@ -13,9 +14,33 @@ export const Plugin = PluginV2.define({
|
||||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const transform = yield* catalog.transform()
|
||||
const integrationTransform = yield* integrations.transform()
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||
),
|
||||
)
|
||||
yield* integrationTransform((integrations) => {
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = Integration.ID.make(id)
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
})
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
yield* transform((catalog) => {
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
@@ -28,8 +53,6 @@ export const Plugin = PluginV2.define({
|
||||
const providerID = ProviderV2.ID.make(id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.env !== undefined) provider.env = [...item.env]
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface Interface {
|
||||
readonly all: () => Effect.Effect<Stored[]>
|
||||
/** Returns stored credentials belonging to one integration. */
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
|
||||
/** Replaces any credential for an integration and returns the new record. */
|
||||
readonly create: (input: {
|
||||
readonly integrationID: IntegrationSchema.ID
|
||||
@@ -350,6 +352,11 @@ export const layer = Layer.effect(
|
||||
return credential ? [credential] : []
|
||||
})
|
||||
}),
|
||||
get: Effect.fn("Credential.get")(function* (id) {
|
||||
if (isolated) return find(id) // kilocode_change - injected workspace credentials are process-local
|
||||
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
|
||||
return row ? stored(row) : undefined
|
||||
}),
|
||||
create: Effect.fn("Credential.create")(function* (input) {
|
||||
const credential = new Stored({
|
||||
id: ID.create(),
|
||||
|
||||
@@ -29,15 +29,16 @@ export const When = Schema.Struct({
|
||||
}).annotate({ identifier: "Integration.When" })
|
||||
export type When = typeof When.Type
|
||||
|
||||
export class TextPrompt extends Schema.Class<TextPrompt>("Integration.TextPrompt")({
|
||||
export const TextPrompt = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
placeholder: Schema.optional(Schema.String),
|
||||
when: Schema.optional(When),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.TextPrompt" })
|
||||
export type TextPrompt = typeof TextPrompt.Type
|
||||
|
||||
export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.SelectPrompt")({
|
||||
export const SelectPrompt = Schema.Struct({
|
||||
type: Schema.Literal("select"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
@@ -49,27 +50,31 @@ export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.Select
|
||||
}),
|
||||
),
|
||||
when: Schema.optional(When),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.SelectPrompt" })
|
||||
export type SelectPrompt = typeof SelectPrompt.Type
|
||||
|
||||
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
export class OAuthMethod extends Schema.Class<OAuthMethod>("Integration.OAuthMethod")({
|
||||
export const OAuthMethod = Schema.Struct({
|
||||
id: MethodID,
|
||||
type: Schema.Literal("oauth"),
|
||||
label: Schema.String,
|
||||
prompts: Schema.optional(Schema.Array(Prompt)),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.OAuthMethod" })
|
||||
export type OAuthMethod = typeof OAuthMethod.Type
|
||||
|
||||
export class KeyMethod extends Schema.Class<KeyMethod>("Integration.KeyMethod")({
|
||||
export const KeyMethod = Schema.Struct({
|
||||
type: Schema.Literal("key"),
|
||||
label: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.KeyMethod" })
|
||||
export type KeyMethod = typeof KeyMethod.Type
|
||||
|
||||
export class EnvMethod extends Schema.Class<EnvMethod>("Integration.EnvMethod")({
|
||||
export const EnvMethod = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
names: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.EnvMethod" })
|
||||
export type EnvMethod = typeof EnvMethod.Type
|
||||
|
||||
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Method = typeof Method.Type
|
||||
@@ -197,7 +202,11 @@ export interface Interface {
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Returns all integrations with their methods and current connections. */
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly connect: {
|
||||
readonly connection: {
|
||||
/** Returns active connections for every registered or credential-backed integration. */
|
||||
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
|
||||
/** Returns the active connection for one integration. */
|
||||
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||
/** Runs a key method and stores the resulting credential. */
|
||||
readonly key: (input: {
|
||||
/** Integration receiving the credential. */
|
||||
@@ -218,6 +227,13 @@ export interface Interface {
|
||||
/** User-facing label for the credential created on completion. */
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Attempt, AuthorizationError>
|
||||
/** Updates a stored credential exposed as a connection. */
|
||||
readonly update: (
|
||||
credentialID: Credential.ID,
|
||||
updates: Partial<Pick<Credential.Stored, "label">>,
|
||||
) => Effect.Effect<void>
|
||||
/** Removes a stored credential connection. */
|
||||
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
}
|
||||
readonly attempt: {
|
||||
/** Returns the current state of an OAuth attempt. */
|
||||
@@ -328,23 +344,32 @@ export const locationLayer = Layer.effect(
|
||||
})
|
||||
|
||||
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
|
||||
const connected = saved.map(
|
||||
(credential) =>
|
||||
new IntegrationConnection.CredentialInfo({ type: "credential", id: credential.id, label: credential.label }),
|
||||
)
|
||||
const connected = saved.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
const detected = entry.methods
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
||||
.map(
|
||||
(name, index) =>
|
||||
new IntegrationConnection.EnvInfo({
|
||||
type: "env",
|
||||
name,
|
||||
}),
|
||||
)
|
||||
.map((name) => ({ type: "env" as const, name }))
|
||||
return [...connected, ...detected]
|
||||
}
|
||||
|
||||
const activeConnection = (
|
||||
entry: Entry | undefined,
|
||||
saved: readonly Credential.Stored[],
|
||||
): IntegrationConnection.Info | undefined => {
|
||||
const credential = saved.at(-1)
|
||||
if (credential) return { type: "credential", id: credential.id, label: credential.label }
|
||||
if (!entry) return
|
||||
const name = entry.methods
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names)
|
||||
.find((name) => process.env[name])
|
||||
if (name) return { type: "env", name }
|
||||
}
|
||||
|
||||
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
|
||||
new Info({
|
||||
id: entry.ref.id,
|
||||
@@ -412,6 +437,7 @@ export const locationLayer = Layer.effect(
|
||||
return [attempt, new Map(current).set(attemptID, terminal)]
|
||||
})
|
||||
if (!result) return settled
|
||||
if (Exit.isSuccess(settled)) yield* events.publish(Event.Updated, {})
|
||||
yield* close(result.scope)
|
||||
return settled
|
||||
}),
|
||||
@@ -454,8 +480,21 @@ export const locationLayer = Layer.effect(
|
||||
}),
|
||||
)).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
connect: {
|
||||
key: Effect.fn("Integration.connect.key")(function* (input) {
|
||||
connection: {
|
||||
list: Effect.fn("Integration.connection.list")(function* () {
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
return new Map(
|
||||
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
|
||||
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
|
||||
return connection ? [[id, connection] as const] : []
|
||||
}),
|
||||
)
|
||||
}),
|
||||
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
return activeConnection(entry, yield* credentials.list(id))
|
||||
}),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
const method = state
|
||||
.get()
|
||||
.integrations.get(input.integrationID)
|
||||
@@ -466,8 +505,9 @@ export const locationLayer = Layer.effect(
|
||||
label: input.label,
|
||||
value: new Credential.Key({ type: "key", key: input.key }),
|
||||
})
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
oauth: Effect.fn("Integration.connect.oauth")(function* (input) {
|
||||
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
|
||||
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
|
||||
if (!method) {
|
||||
return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`)
|
||||
@@ -510,6 +550,14 @@ export const locationLayer = Layer.effect(
|
||||
time,
|
||||
})
|
||||
}),
|
||||
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
|
||||
yield* credentials.update(credentialID, updates)
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
|
||||
yield* credentials.remove(credentialID)
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
},
|
||||
attempt: {
|
||||
status: Effect.fn("Integration.attempt.status")(function* (attemptID) {
|
||||
|
||||
@@ -3,16 +3,18 @@ export * as IntegrationConnection from "./connection"
|
||||
import { Schema } from "effect"
|
||||
import { Credential } from "../credential"
|
||||
|
||||
export class CredentialInfo extends Schema.Class<CredentialInfo>("Connection.CredentialInfo")({
|
||||
export const CredentialInfo = Schema.Struct({
|
||||
type: Schema.Literal("credential"),
|
||||
id: Credential.ID,
|
||||
label: Schema.String,
|
||||
}) {}
|
||||
}).annotate({ identifier: "Connection.CredentialInfo" })
|
||||
export type CredentialInfo = typeof CredentialInfo.Type
|
||||
|
||||
export class EnvInfo extends Schema.Class<EnvInfo>("Connection.EnvInfo")({
|
||||
export const EnvInfo = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
}).annotate({ identifier: "Connection.EnvInfo" })
|
||||
export type EnvInfo = typeof EnvInfo.Type
|
||||
|
||||
export const Info = Schema.Union([CredentialInfo, EnvInfo])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
export function args(command: string) {
|
||||
return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)]
|
||||
}
|
||||
|
||||
const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false);
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false);
|
||||
$OutputEncoding = [Console]::OutputEncoding;
|
||||
`
|
||||
|
||||
function script(command: string) {
|
||||
const pos = prologue(command)
|
||||
const head = command.slice(0, pos)
|
||||
const body = command.slice(pos)
|
||||
const gap = head && !/[;\r\n]\s*$/.test(head) ? "\n" : ""
|
||||
return `${head}${gap}${setup}${body}`
|
||||
}
|
||||
|
||||
function prologue(command: string) {
|
||||
const pos = scan(command, 0)
|
||||
const attr = attrs(command, pos)
|
||||
const body = command.slice(attr)
|
||||
const match = /^param\s*\(/i.exec(body)
|
||||
if (!match) return pos
|
||||
|
||||
const start = attr + match[0].lastIndexOf("(")
|
||||
const end = block(command, start, "(", ")")
|
||||
if (end === undefined) return pos
|
||||
return end
|
||||
}
|
||||
|
||||
function attrs(command: string, start: number) {
|
||||
let pos = start
|
||||
while (pos < command.length) {
|
||||
const next = scan(command, pos)
|
||||
if (command[next] !== "[") return next
|
||||
const end = block(command, next, "[", "]")
|
||||
if (end === undefined) return start
|
||||
pos = end
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
function scan(command: string, start: number) {
|
||||
let pos = start
|
||||
while (pos < command.length) {
|
||||
const next = trivia(command, pos)
|
||||
if (next !== pos) {
|
||||
pos = next
|
||||
continue
|
||||
}
|
||||
const end = line(command, pos)
|
||||
const value = command.slice(pos, end)
|
||||
if (/^using\s+(?:assembly|module|namespace|type)\b/i.test(value)) {
|
||||
pos = end
|
||||
continue
|
||||
}
|
||||
return pos
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
function trivia(command: string, start: number) {
|
||||
let pos = start
|
||||
while (pos < command.length) {
|
||||
while (/\s/.test(command[pos] ?? "")) pos++
|
||||
if (command[pos] === "#") {
|
||||
pos = line(command, pos)
|
||||
continue
|
||||
}
|
||||
if (command.startsWith("<#", pos)) {
|
||||
const end = command.indexOf("#>", pos + 2)
|
||||
if (end === -1) return command.length
|
||||
pos = end + 2
|
||||
continue
|
||||
}
|
||||
return pos
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
function line(command: string, start: number) {
|
||||
const index = command.indexOf("\n", start)
|
||||
if (index === -1) return command.length
|
||||
return index + 1
|
||||
}
|
||||
|
||||
function block(command: string, start: number, open: string, close: string) {
|
||||
let depth = 0
|
||||
let quote: string | undefined
|
||||
for (let pos = start; pos < command.length; pos++) {
|
||||
const char = command[pos]
|
||||
if (quote) {
|
||||
if (quote === "'" && char === "'" && command[pos + 1] === "'") {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if (quote === '"' && char === "`") {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if (char === quote) quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
if (command.startsWith("<#", pos)) {
|
||||
const end = command.indexOf("#>", pos + 2)
|
||||
if (end === -1) return
|
||||
pos = end + 1
|
||||
continue
|
||||
}
|
||||
if (char === "#") {
|
||||
pos = line(command, pos) - 1
|
||||
continue
|
||||
}
|
||||
if (char === open) depth++
|
||||
if (char === close) {
|
||||
depth--
|
||||
if (depth === 0) return pos + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const PowerShell = { args }
|
||||
@@ -0,0 +1,61 @@
|
||||
import path from "path"
|
||||
|
||||
type Input = {
|
||||
command?: string
|
||||
args?: string[]
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
type Command = {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
const names = new Set(["kilo", "kilocode"])
|
||||
const self = command()
|
||||
|
||||
function clean(input: string[]) {
|
||||
return input.filter((arg, index) => {
|
||||
if (arg === "--cwd") return false
|
||||
if (input[index - 1] === "--cwd") return false
|
||||
if (arg.startsWith("--cwd=")) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function full(input: string, cwd: string) {
|
||||
if (path.isAbsolute(input)) return input
|
||||
return path.resolve(cwd, input)
|
||||
}
|
||||
|
||||
export function command(
|
||||
proc = { argv: process.argv, execArgv: process.execArgv, execPath: process.execPath, cwd: process.cwd() },
|
||||
): Command {
|
||||
const script = proc.argv[1]
|
||||
const bundled = script?.startsWith("/$bunfs/") || (script ? /^[A-Za-z]:[\\/]~BUN[\\/]/.test(script) : false)
|
||||
if (script && !bundled && /\.(ts|js|mjs|cjs)$/.test(script)) {
|
||||
const file = full(script, proc.cwd)
|
||||
const dir = path.dirname(file)
|
||||
const root = path.basename(dir) === "src" ? path.dirname(dir) : proc.cwd
|
||||
return { command: full(proc.execPath, proc.cwd), args: [...clean(proc.execArgv), file], cwd: root }
|
||||
}
|
||||
return { command: full(proc.execPath, proc.cwd), args: [] }
|
||||
}
|
||||
|
||||
export function resolve(input: Input, cmd = self): Input {
|
||||
if (!input.command || !names.has(input.command)) return input
|
||||
const args = input.args ?? []
|
||||
const project = cmd.cwd && args.length === 0 && input.cwd ? [input.cwd] : []
|
||||
return {
|
||||
...input,
|
||||
command: cmd.command,
|
||||
args: [...cmd.args, ...project, ...args],
|
||||
cwd: cmd.cwd ?? input.cwd,
|
||||
}
|
||||
}
|
||||
|
||||
export const KiloPtySelfCommand = {
|
||||
command,
|
||||
resolve,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Project } from "./project"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { AbsolutePath, optionalOmitUndefined } from "./schema"
|
||||
import { WorkspaceV2 } from "./workspace"
|
||||
|
||||
export * as Location from "./location"
|
||||
@@ -12,7 +12,7 @@ export class Ref extends Schema.Class<Ref>("Location.Ref")({
|
||||
|
||||
export class Info extends Schema.Class<Info>("Location.Info")({
|
||||
directory: AbsolutePath,
|
||||
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
|
||||
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
|
||||
project: Schema.Struct({
|
||||
id: Project.ID,
|
||||
directory: AbsolutePath,
|
||||
|
||||
@@ -21,7 +21,6 @@ import { PluginV2 } from "../plugin"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { EnvPlugin } from "./env"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillV2 } from "../skill"
|
||||
@@ -98,7 +97,6 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const boot = Effect.gen(function* () {
|
||||
yield* add(EnvPlugin)
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
// kilocode_change - Kilo's CLI registry supplies `kilo-config`; do not register the redundant opencode skill.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../plugin"
|
||||
|
||||
export const EnvPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("env"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
const key = item.provider.env.find((env) => process.env[env])
|
||||
if (!key) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.enabled = {
|
||||
via: "env",
|
||||
name: key,
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -70,16 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||
integrations.update(integrationID, (integration) => (integration.name = item.name))
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: new Integration.KeyMethod({
|
||||
type: "key",
|
||||
}),
|
||||
method: { type: "key" },
|
||||
})
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: new Integration.EnvMethod({
|
||||
type: "env",
|
||||
names: [...item.env],
|
||||
}),
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -88,7 +83,6 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||
const providerID = ProviderV2.ID.make(item.id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = item.name
|
||||
provider.env = [...item.env]
|
||||
provider.api = item.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
|
||||
@@ -24,7 +24,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({
|
||||
apiKey: config.apiKey,
|
||||
options: gatewayOptions(evt.options, metadata),
|
||||
} as any)
|
||||
const unified = createUnified()
|
||||
const unified = createUnified({ apiKey: config.apiKey })
|
||||
evt.sdk = {
|
||||
languageModel(modelID: string) {
|
||||
return gateway(unified(modelID))
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import { Integration } from "../../integration"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider" // kilocode_change
|
||||
|
||||
export const LLMGatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("llmgateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.enabled === false) continue
|
||||
if (item.provider.disabled) continue
|
||||
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
||||
|
||||
@@ -32,11 +32,11 @@ const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||
|
||||
export const browser = {
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
method: new Integration.OAuthMethod({
|
||||
method: {
|
||||
id: browserMethodID,
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (browser)",
|
||||
}),
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
@@ -95,11 +95,11 @@ export const browser = {
|
||||
|
||||
export const headless = {
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
method: new Integration.OAuthMethod({
|
||||
method: {
|
||||
id: headlessMethodID,
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (headless)",
|
||||
}),
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Effect } from "effect"
|
||||
import { Integration } from "../../integration"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const OpencodePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("opencode"),
|
||||
effect: Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
let hasKey = false
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.opencode)
|
||||
if (!item) return
|
||||
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
|
||||
hasKey = Boolean(
|
||||
process.env.OPENCODE_API_KEY ||
|
||||
item.provider.env.some((env) => process.env[env]) ||
|
||||
item.provider.request.body.apiKey ||
|
||||
(item.provider.enabled && item.provider.enabled.via === "credential"),
|
||||
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
|
||||
)
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) provider.request.body.apiKey = "public"
|
||||
|
||||
@@ -23,7 +23,7 @@ export const Plugin = PluginV2.define({
|
||||
skill: new SkillV2.Info({
|
||||
name: "customize-opencode",
|
||||
description:
|
||||
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
|
||||
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
|
||||
location: AbsolutePath.make("/builtin/customize-opencode.md"),
|
||||
content: CustomizeOpencodeContent,
|
||||
}),
|
||||
|
||||
@@ -43,6 +43,8 @@ already-loaded config until then.
|
||||
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
|
||||
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
|
||||
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
|
||||
| Project commands | `.opencode/command/<name>.md` or `.opencode/commands/<name>.md` |
|
||||
| Global commands | `~/.config/opencode/command(s)/<name>.md` |
|
||||
| Project skills | `.opencode/skill(s)/<name>/SKILL.md` |
|
||||
| Global skills | `~/.config/opencode/skill(s)/<name>/SKILL.md` |
|
||||
| External skills (auto-loaded) | `~/.claude/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` |
|
||||
@@ -96,7 +98,7 @@ Every field is optional.
|
||||
},
|
||||
|
||||
"command": {
|
||||
"deploy": { "description": "...", "prompt": "..." }
|
||||
"deploy": { "description": "...", "template": "..." }
|
||||
},
|
||||
|
||||
"provider": {
|
||||
@@ -151,6 +153,7 @@ Shape notes worth being explicit about:
|
||||
- `skills` is an object with `paths` and/or `urls`, not an array.
|
||||
- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand.
|
||||
- `agent` is an object keyed by agent name, not an array.
|
||||
- `command` is an object keyed by command name, not an array.
|
||||
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
|
||||
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
|
||||
- `permission` is either a string action or an object keyed by tool name.
|
||||
@@ -277,6 +280,31 @@ opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agent
|
||||
`compaction`, `title`, `summary`. To override a built-in's fields, define the
|
||||
same key in `agent: { <name>: { ... } }`.
|
||||
|
||||
## Commands
|
||||
|
||||
opencode's command loader scans for `**/*.md` inside command directories. The
|
||||
file is named after the command, and lives directly inside the `command` folder:
|
||||
|
||||
```
|
||||
.opencode/command/deploy.md
|
||||
```
|
||||
|
||||
Frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: One sentence describing what the command does.
|
||||
agent: build
|
||||
model: anthropic/claude-sonnet-4-6
|
||||
---
|
||||
|
||||
(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input)
|
||||
```
|
||||
|
||||
- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter.
|
||||
- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments.
|
||||
- Optional: `description`, `agent`, `model`, `variant`, `subtask`.
|
||||
|
||||
## Plugins
|
||||
|
||||
`plugin:` is an array. Each entry is one of:
|
||||
@@ -300,7 +328,7 @@ function, not a plain object literal, and the function returns an object
|
||||
(return `{}` if there is nothing to register).
|
||||
|
||||
```ts
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import type { Plugin } from "@kilocode/plugin"
|
||||
|
||||
export default (async ({ client, project, directory, $ }) => {
|
||||
return {
|
||||
@@ -397,16 +425,16 @@ the `plan` agent's permission ruleset (`edit: deny *`).
|
||||
|
||||
When a user's config is broken and opencode won't start, these env vars help:
|
||||
|
||||
- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
|
||||
- `KILO_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
|
||||
and start from globals only. Run from the project directory, opencode loads,
|
||||
the user edits the broken file, then they restart without the flag.
|
||||
- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config.
|
||||
- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
|
||||
- `KILO_CONFIG=/path/to/file.json`: load an additional explicit config.
|
||||
- `KILO_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
|
||||
inject inline JSON as a final local-scope merge.
|
||||
- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
|
||||
- `OPENCODE_PURE=1`: skip external plugins entirely.
|
||||
- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`,
|
||||
`OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
|
||||
- `KILO_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
|
||||
- `KILO_PURE=1`: skip external plugins entirely.
|
||||
- `KILO_DISABLE_EXTERNAL_SKILLS=1`,
|
||||
`KILO_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
|
||||
`~/.claude/` and `~/.agents/`.
|
||||
|
||||
## When proposing edits
|
||||
@@ -415,8 +443,8 @@ When a user's config is broken and opencode won't start, these env vars help:
|
||||
exact shape, or the field is not covered in this skill, fetch
|
||||
`https://opencode.ai/config.json` and read the schema rather than guessing.
|
||||
- Preserve `$schema` and any existing fields the user did not ask to change.
|
||||
- For agent, skill, and plugin definitions, prefer creating new files in the
|
||||
correct location over inlining everything in `opencode.json`.
|
||||
- For agent, command, skill, and plugin definitions, prefer creating new files
|
||||
in the correct location over inlining everything in `opencode.json`.
|
||||
- If the user's existing config is malformed, point them at the env-var escape
|
||||
hatches above so they can edit from inside opencode without breaking their
|
||||
session.
|
||||
|
||||
@@ -245,6 +245,7 @@ export const layer = Layer.effect(
|
||||
(sourceDirectory) =>
|
||||
Effect.forEach(strategies(), (strategy) =>
|
||||
strategy.list(sourceDirectory).pipe(
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
Effect.map((items) =>
|
||||
items.map((item) => ({
|
||||
directory: item.directory,
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as ProviderV2 from "./provider"
|
||||
|
||||
import { withStatics } from "./schema"
|
||||
import { Schema } from "effect"
|
||||
import { Credential } from "./credential"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("ProviderV2.ID"),
|
||||
@@ -49,22 +48,7 @@ export type Request = typeof Request.Type
|
||||
export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
enabled: Schema.Union([
|
||||
Schema.Literal(false),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("env"),
|
||||
name: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("credential"),
|
||||
credentialID: Credential.ID,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("custom"),
|
||||
data: Schema.Record(Schema.String, Schema.Any),
|
||||
}),
|
||||
]),
|
||||
env: Schema.String.pipe(Schema.Array),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
api: Api,
|
||||
request: Request,
|
||||
}) {
|
||||
@@ -72,8 +56,6 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
||||
return new Info({
|
||||
id: providerID,
|
||||
name: providerID,
|
||||
enabled: false,
|
||||
env: [],
|
||||
api: {
|
||||
type: "native",
|
||||
settings: {},
|
||||
|
||||
+155
-107
@@ -2,23 +2,29 @@ export * as Pty from "./pty"
|
||||
|
||||
import type { Disp, Proc } from "#pty"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { EventV2 } from "./event"
|
||||
import { Location } from "./location"
|
||||
import { NonNegativeInt, PositiveInt } from "./schema"
|
||||
import { PtyID } from "./pty/schema"
|
||||
import { SessionSchema } from "./session/schema" // kilocode_change
|
||||
import { Shell } from "./shell"
|
||||
import { lazy } from "./util/lazy"
|
||||
import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change
|
||||
|
||||
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||
const BUFFER_CHUNK = 64 * 1024
|
||||
const encoder = new TextEncoder()
|
||||
// Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
|
||||
// Cap retention so abandoned terminals do not accumulate unbounded buffers.
|
||||
const EXITED_LIMIT = 25
|
||||
const pty = lazy(() => import("#pty"))
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
data?: unknown
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
type Subscriber = {
|
||||
readonly onData: (chunk: string) => void
|
||||
readonly onEnd: (event: { exitCode?: number }) => void
|
||||
active: boolean
|
||||
detached: boolean
|
||||
pending: string[]
|
||||
end?: { exitCode?: number }
|
||||
}
|
||||
|
||||
type Active = {
|
||||
@@ -27,22 +33,10 @@ type Active = {
|
||||
buffer: string
|
||||
bufferCursor: number
|
||||
cursor: number
|
||||
subscribers: Map<unknown, Socket>
|
||||
subscribers: Map<object, Subscriber>
|
||||
listeners: Disp[]
|
||||
}
|
||||
|
||||
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
|
||||
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
const bytes = encoder.encode(json)
|
||||
const out = new Uint8Array(bytes.length + 1)
|
||||
out[0] = 0
|
||||
out.set(bytes, 1)
|
||||
return out
|
||||
}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: PtyID,
|
||||
title: Schema.String,
|
||||
@@ -52,6 +46,8 @@ export const Info = Schema.Struct({
|
||||
status: Schema.Literals(["running", "exited"]),
|
||||
// Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time.
|
||||
pid: NonNegativeInt,
|
||||
// Present once status is "exited".
|
||||
exitCode: Schema.optional(NonNegativeInt),
|
||||
sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change
|
||||
}).annotate({ identifier: "Pty" })
|
||||
|
||||
@@ -67,14 +63,6 @@ export const CreateInput = Schema.Struct({
|
||||
|
||||
export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
|
||||
|
||||
export type PreparedCreate = {
|
||||
readonly command: string
|
||||
readonly args: string[]
|
||||
readonly cwd: string
|
||||
readonly title?: string
|
||||
readonly env: Record<string, string>
|
||||
}
|
||||
|
||||
export const UpdateInput = Schema.Struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change
|
||||
@@ -88,10 +76,34 @@ export const UpdateInput = Schema.Struct({
|
||||
|
||||
export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
|
||||
|
||||
export type AttachInput = {
|
||||
// Absolute output cursor to replay from. -1 tails from the current end; omitted replays the full retained buffer.
|
||||
readonly cursor?: number
|
||||
// Callbacks fire synchronously from the native PTY data path; keep them non-blocking.
|
||||
readonly onData: (chunk: string) => void
|
||||
// Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown.
|
||||
readonly onEnd: (event: { exitCode?: number }) => void
|
||||
}
|
||||
|
||||
export type Attachment = {
|
||||
// Retained output from the requested cursor to the current end.
|
||||
readonly replay: string
|
||||
// Absolute output cursor after replay.
|
||||
readonly cursor: number
|
||||
readonly write: (data: string) => void
|
||||
// Starts live delivery after the caller has applied replay and cursor metadata.
|
||||
readonly activate: () => void
|
||||
readonly detach: () => void
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
|
||||
ptyID: PtyID,
|
||||
}) {}
|
||||
|
||||
export class ExitedError extends Schema.TaggedErrorClass<ExitedError>()("Pty.ExitedError", {
|
||||
ptyID: PtyID,
|
||||
}) {}
|
||||
|
||||
export const Event = {
|
||||
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
|
||||
Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
|
||||
@@ -102,19 +114,11 @@ export const Event = {
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly create: (input: PreparedCreate) => Effect.Effect<Info>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info>
|
||||
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
||||
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
|
||||
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
|
||||
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
|
||||
readonly connect: (
|
||||
id: PtyID,
|
||||
ws: Socket,
|
||||
cursor?: number,
|
||||
) => Effect.Effect<
|
||||
{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
|
||||
NotFoundError
|
||||
>
|
||||
readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Pty") {}
|
||||
@@ -124,28 +128,41 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const location = yield* Location.Service
|
||||
const config = yield* Config.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<PtyID, Active>()
|
||||
const exitOrder: PtyID[] = []
|
||||
|
||||
function notifyEnd(session: Active, event: { exitCode?: number }) {
|
||||
for (const subscriber of session.subscribers.values()) {
|
||||
if (!subscriber.active) {
|
||||
subscriber.end = event
|
||||
continue
|
||||
}
|
||||
try {
|
||||
subscriber.onEnd(event)
|
||||
} catch {}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
}
|
||||
|
||||
function teardown(session: Active) {
|
||||
for (const listener of session.listeners) listener.dispose()
|
||||
session.listeners.length = 0
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [sub, ws] of session.subscribers.entries()) {
|
||||
if (session.info.status === "running") {
|
||||
try {
|
||||
if (sock(ws) === sub) ws.close()
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
notifyEnd(session, {})
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const session of sessions.values()) teardown(session)
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -157,12 +174,13 @@ export const layer = Layer.effect(
|
||||
|
||||
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return false
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
yield* Effect.logInfo("removing session", { id })
|
||||
teardown(session)
|
||||
yield* events.publish(Event.Deleted, { id: session.info.id })
|
||||
return true
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||
@@ -178,29 +196,47 @@ export const layer = Layer.effect(
|
||||
return (yield* requireSession(id)).info
|
||||
})
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
|
||||
// kilocode_change start - resolve Kilo self-commands to the real binary, arguments, and project cwd
|
||||
const resolved = KiloPtySelfCommand.resolve({
|
||||
command: input.command,
|
||||
args: input.args ? [...input.args] : undefined,
|
||||
cwd: input.cwd,
|
||||
})
|
||||
const command = resolved.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
|
||||
const base = resolved.args ?? []
|
||||
const args = Shell.login(command) ? [...base, "-l"] : [...base]
|
||||
const cwd = resolved.cwd || location.directory
|
||||
// kilocode_change end
|
||||
const env = {
|
||||
...process.env,
|
||||
...input.env,
|
||||
TERM: "xterm-256color",
|
||||
KILO_TERMINAL: "1",
|
||||
KILO_PTY_ID: id, // kilocode_change - let nested Kilo processes identify their parent terminal
|
||||
} as Record<string, string>
|
||||
// kilocode_change start - do not expose the local server credential to processes spawned by user terminals
|
||||
delete env.KILO_SERVER_PASSWORD
|
||||
delete env.KILO_SERVER_USERNAME
|
||||
// kilocode_change end
|
||||
if (process.platform === "win32") {
|
||||
env.LC_ALL = "C.UTF-8"
|
||||
env.LC_CTYPE = "C.UTF-8"
|
||||
env.LANG = "C.UTF-8"
|
||||
}
|
||||
yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
|
||||
const { spawn } = yield* Effect.promise(() => pty())
|
||||
// kilocode_change - expose the pty id to the spawned shell so a nested `kilo tui`/`kilo run` can
|
||||
// detect it is running inside a kilo-spawned terminal (read via process.env.KILO_PTY_ID)
|
||||
const env = { ...input.env, KILO_PTY_ID: id }
|
||||
const proc = yield* Effect.sync(() =>
|
||||
spawn(input.command, input.args, {
|
||||
name: "xterm-256color",
|
||||
cwd: input.cwd,
|
||||
env,
|
||||
}),
|
||||
)
|
||||
const info = {
|
||||
const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
|
||||
const info: Info = {
|
||||
id,
|
||||
title: input.title || `Terminal ${id.slice(-4)}`,
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
status: "running",
|
||||
pid: proc.pid,
|
||||
} as const
|
||||
}
|
||||
const session: Active = {
|
||||
info,
|
||||
process: proc,
|
||||
@@ -214,15 +250,15 @@ export const layer = Layer.effect(
|
||||
session.listeners.push(
|
||||
proc.onData((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
if (ws.readyState !== 1 || sock(ws) !== key) {
|
||||
session.subscribers.delete(key)
|
||||
for (const [token, subscriber] of session.subscribers.entries()) {
|
||||
if (!subscriber.active) {
|
||||
subscriber.pending.push(chunk)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
ws.send(chunk)
|
||||
subscriber.onData(chunk)
|
||||
} catch {
|
||||
session.subscribers.delete(key)
|
||||
session.subscribers.delete(token)
|
||||
}
|
||||
}
|
||||
session.buffer += chunk
|
||||
@@ -233,12 +269,19 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
proc.onExit(({ exitCode }) => {
|
||||
if (session.info.status === "exited") return
|
||||
session.info.status = "exited"
|
||||
session.info.exitCode = exitCode
|
||||
notifyEnd(session, { exitCode })
|
||||
exitOrder.push(id)
|
||||
runFork(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logInfo("session exited", { id, exitCode })
|
||||
session.info.status = "exited"
|
||||
yield* events.publish(Event.Exited, { id, exitCode })
|
||||
yield* removeSession(id)
|
||||
while (exitOrder.length > EXITED_LIMIT) {
|
||||
const oldest = exitOrder[0]
|
||||
if (!oldest) break
|
||||
yield* removeSession(oldest)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
@@ -253,66 +296,71 @@ export const layer = Layer.effect(
|
||||
// kilocode_change start - associate nested Kilo TUI terminals with the viewed session
|
||||
if ("sessionID" in input) session.info.sessionID = input.sessionID ?? undefined
|
||||
// kilocode_change end
|
||||
if (input.size) session.process.resize(input.size.cols, input.size.rows)
|
||||
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
|
||||
yield* events.publish(Event.Updated, { info: session.info })
|
||||
return session.info
|
||||
})
|
||||
|
||||
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") session.process.resize(cols, rows)
|
||||
})
|
||||
|
||||
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") session.process.write(data)
|
||||
})
|
||||
|
||||
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
|
||||
const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
|
||||
yield* Effect.logInfo("client connected to session", { id, directory: location.directory })
|
||||
const sub = sock(ws)
|
||||
session.subscribers.delete(sub)
|
||||
session.subscribers.set(sub, ws)
|
||||
const cleanup = () => session.subscribers.delete(sub)
|
||||
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
|
||||
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
|
||||
const token = {}
|
||||
const subscriber: Subscriber = {
|
||||
onData: input.onData,
|
||||
onEnd: input.onEnd,
|
||||
active: false,
|
||||
detached: false,
|
||||
pending: [],
|
||||
}
|
||||
session.subscribers.set(token, subscriber)
|
||||
const start = session.bufferCursor
|
||||
const end = session.cursor
|
||||
const from =
|
||||
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
|
||||
const data = (() => {
|
||||
input.cursor === -1
|
||||
? end
|
||||
: typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
|
||||
? Math.max(0, input.cursor)
|
||||
: 0
|
||||
const replay = (() => {
|
||||
if (!session.buffer || from >= end) return ""
|
||||
const offset = Math.max(0, from - start)
|
||||
if (offset >= session.buffer.length) return ""
|
||||
return session.buffer.slice(offset)
|
||||
})()
|
||||
if (data) {
|
||||
try {
|
||||
for (let i = 0; i < data.length; i += BUFFER_CHUNK) ws.send(data.slice(i, i + BUFFER_CHUNK))
|
||||
} catch {
|
||||
cleanup()
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
ws.send(meta(end))
|
||||
} catch {
|
||||
cleanup()
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
return {
|
||||
onMessage: (message: string | ArrayBuffer) => {
|
||||
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
|
||||
replay,
|
||||
cursor: end,
|
||||
write: (data: string) => {
|
||||
if (session.info.status === "running") session.process.write(data)
|
||||
},
|
||||
onClose: () => {
|
||||
cleanup()
|
||||
activate: () => {
|
||||
if (subscriber.active || subscriber.detached) return
|
||||
subscriber.active = true
|
||||
try {
|
||||
for (const chunk of subscriber.pending) subscriber.onData(chunk)
|
||||
subscriber.pending.length = 0
|
||||
if (subscriber.end) subscriber.onEnd(subscriber.end)
|
||||
} catch {
|
||||
session.subscribers.delete(token)
|
||||
}
|
||||
},
|
||||
detach: () => {
|
||||
subscriber.detached = true
|
||||
subscriber.pending.length = 0
|
||||
subscriber.end = undefined
|
||||
session.subscribers.delete(token)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ list, get, create, update, remove, resize, write, connect })
|
||||
return Service.of({ list, get, create, update, remove, write, attach })
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
|
||||
|
||||
export function handlePtyInput(
|
||||
handler: { onMessage: (message: string | ArrayBuffer) => void },
|
||||
message: string | Uint8Array,
|
||||
) {
|
||||
if (typeof message === "string") {
|
||||
handler.onMessage(message)
|
||||
return Effect.void
|
||||
}
|
||||
return Effect.try({
|
||||
try: () => inputDecoder.decode(message),
|
||||
catch: () => new Error("invalid PTY websocket input"),
|
||||
}).pipe(
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.flatMap((decoded) => {
|
||||
if (decoded === undefined) return Effect.void
|
||||
handler.onMessage(decoded)
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export * as PtyProtocol from "./protocol"
|
||||
|
||||
// Wire protocol for PTY websocket transports. The PTY domain service is transport-free; server
|
||||
// routes adapt Pty.attach to websockets with these helpers so every surface speaks one protocol.
|
||||
//
|
||||
// Outbound frames are raw UTF-8 terminal chunks. One control frame — a 0x00 byte followed by
|
||||
// UTF-8 JSON — carries the absolute output cursor after replay so clients can resume later.
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||
|
||||
// Replay can be megabytes; send it in bounded frames.
|
||||
export const REPLAY_CHUNK = 64 * 1024
|
||||
|
||||
export function metaFrame(cursor: number) {
|
||||
const bytes = encoder.encode(JSON.stringify({ cursor }))
|
||||
const out = new Uint8Array(bytes.length + 1)
|
||||
out[0] = 0
|
||||
out.set(bytes, 1)
|
||||
return out
|
||||
}
|
||||
|
||||
export function chunks(data: string) {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < data.length; i += REPLAY_CHUNK) out.push(data.slice(i, i + REPLAY_CHUNK))
|
||||
return out
|
||||
}
|
||||
|
||||
// Inbound client frames are UTF-8 text or binary; invalid UTF-8 input is dropped.
|
||||
export function decodeInput(message: string | Uint8Array | ArrayBuffer) {
|
||||
if (typeof message === "string") return message
|
||||
try {
|
||||
return decoder.decode(message instanceof ArrayBuffer ? new Uint8Array(message) : message)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionRunner from "./index"
|
||||
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Schema } from "effect"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
@@ -9,20 +9,11 @@ import type { SystemContext } from "../../system-context/index"
|
||||
import type { SessionContextEpoch } from "../context-epoch"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
||||
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
|
||||
"SessionRunner.StepLimitExceededError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
limit: Schema.Int,
|
||||
},
|
||||
) {}
|
||||
|
||||
export type RunError =
|
||||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| ContextSnapshotDecodeError
|
||||
| StepLimitExceededError
|
||||
| SystemContext.InitializationBlocked
|
||||
| SessionContextEpoch.AgentReplacementBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
LLMClient,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Message,
|
||||
SystemPart,
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
@@ -29,10 +30,11 @@ import { SessionHistory } from "../history"
|
||||
import { SessionInput } from "../input"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { type RunError, Service, StepLimitExceededError } from "./index"
|
||||
import { type RunError, Service } from "./index"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
||||
import { toLLMMessages } from "./to-llm-message"
|
||||
import { MAX_STEPS_PROMPT } from "./max-steps"
|
||||
|
||||
/**
|
||||
* Runs one durable coding-agent Session until it settles.
|
||||
@@ -45,7 +47,7 @@ import { toLLMMessages } from "./to-llm-message"
|
||||
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
|
||||
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
|
||||
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
|
||||
* - [x] Bound model steps.
|
||||
* - [x] Honor optional agent step limits.
|
||||
* - [ ] Bound provider retries and repeated identical tool calls.
|
||||
*
|
||||
* - Runtime context assembly
|
||||
@@ -80,13 +82,10 @@ import { toLLMMessages } from "./to-llm-message"
|
||||
* Durable activity recovery remains a separate future slice with an explicit retry policy.
|
||||
*
|
||||
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
|
||||
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and a
|
||||
* bounded explicit loop starts the next provider turn after local settlement.
|
||||
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and an
|
||||
* explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop.
|
||||
*/
|
||||
|
||||
// QUESTION: Did this exist previously, or did we add this limit? Does it make sense?
|
||||
const MAX_STEPS = 25
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -175,6 +174,7 @@ export const layer = Layer.effect(
|
||||
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
recoverOverflow?: typeof compaction.compactAfterOverflow,
|
||||
) {
|
||||
const session = yield* getSession(sessionID)
|
||||
@@ -214,7 +214,8 @@ export const layer = Layer.effect(
|
||||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const toolMaterialization = yield* tools.materialize(agent.info?.permissions)
|
||||
const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps
|
||||
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const request = LLM.request({
|
||||
model,
|
||||
@@ -222,8 +223,9 @@ export const layer = Layer.effect(
|
||||
system: [agent.info?.system, system.baseline]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: toLLMMessages(context, model),
|
||||
tools: toolMaterialization.definitions,
|
||||
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
|
||||
tools: toolMaterialization?.definitions ?? [],
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
@@ -254,6 +256,10 @@ export const layer = Layer.effect(
|
||||
}
|
||||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
if (!toolMaterialization) {
|
||||
yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
@@ -340,31 +346,32 @@ export const layer = Layer.effect(
|
||||
type RunTurn = (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
) => Effect.Effect<boolean, RunError>
|
||||
|
||||
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
|
||||
return yield* runTurnAttempt(sessionID, promotion).pipe(
|
||||
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
|
||||
return yield* runTurnAttempt(sessionID, promotion, step).pipe(
|
||||
Effect.catchDefect(
|
||||
Effect.fnUntraced(function* (defect) {
|
||||
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
|
||||
yield* Effect.yieldNow
|
||||
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion)
|
||||
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
|
||||
return yield* runTurnAttempt(sessionID, promotion, compaction.compactAfterOverflow).pipe(
|
||||
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
|
||||
return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe(
|
||||
Effect.catchDefect(
|
||||
Effect.fnUntraced(function* (defect) {
|
||||
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
|
||||
yield* Effect.yieldNow
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined)
|
||||
return yield* runTurn(sessionID, defect.transition.promotion)
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
|
||||
return yield* runTurn(sessionID, defect.transition.promotion, step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -382,14 +389,11 @@ export const layer = Layer.effect(
|
||||
let openActivity = input.force === true || hasSteer || hasQueue
|
||||
while (openActivity) {
|
||||
let needsContinuation = true
|
||||
for (let step = 0; step < MAX_STEPS; step++) {
|
||||
needsContinuation = yield* runTurn(input.sessionID, promotion)
|
||||
for (let step = 1; needsContinuation; step++) {
|
||||
needsContinuation = yield* runTurn(input.sessionID, promotion, step)
|
||||
promotion = "steer"
|
||||
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
if (!needsContinuation) break
|
||||
}
|
||||
if (needsContinuation)
|
||||
return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS })
|
||||
openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
promotion = openActivity ? "queue" : undefined
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
CRITICAL - MAXIMUM STEPS REACHED
|
||||
export const MAX_STEPS_PROMPT = `CRITICAL - MAXIMUM STEPS REACHED
|
||||
|
||||
The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only.
|
||||
|
||||
@@ -13,4 +13,4 @@ Response must include:
|
||||
- List of any remaining tasks that were not completed
|
||||
- Recommendations for what should be done next
|
||||
|
||||
Any attempt to use tools is a critical violation. Respond with text ONLY.
|
||||
Any attempt to use tools is a critical violation. Respond with text ONLY.`
|
||||
@@ -8,6 +8,9 @@ import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { IntegrationConnection } from "../../integration/connection"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginBoot } from "../../plugin/boot"
|
||||
@@ -45,10 +48,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
/** Test or embedding seam for supplying a model resolver directly. */
|
||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||
|
||||
const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => {
|
||||
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
|
||||
if (credential?.value.type === "key") return Auth.value(credential.value.key)
|
||||
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
|
||||
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
||||
if (typeof value === "string") return Auth.value(value)
|
||||
return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
|
||||
return connection?.type === "env" ? Auth.config(connection.name) : undefined
|
||||
}
|
||||
|
||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||
@@ -83,41 +88,54 @@ const apiName = (model: ModelV2.Info) =>
|
||||
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
provider?: ProviderV2.Info,
|
||||
connection?: IntegrationConnection.Info,
|
||||
credential?: Credential.Stored,
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
const key = apiKey(model, provider)
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") {
|
||||
const resolved =
|
||||
credential?.value.metadata === undefined
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
Object.assign(draft.request.body, credential.value.metadata)
|
||||
// kilocode_change start - Kilo Gateway consumes the migrated OAuth account as its organization route
|
||||
if (credential.value.type === "oauth" && credential.value.metadata?.accountID) {
|
||||
draft.request.body.kilocodeOrganizationId = credential.value.metadata.accountID
|
||||
delete draft.request.body.accountID
|
||||
}
|
||||
// kilocode_change end
|
||||
})
|
||||
const key = apiKey(resolved, connection, credential)
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, OpenAIResponses.route)
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: model.api.id }),
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") {
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, AnthropicMessages.route)
|
||||
withDefaults(resolved, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: model.api.id }),
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) {
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, OpenAICompatibleChat.route)
|
||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: model.api.id }),
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
return Effect.fail(
|
||||
new UnsupportedApiError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
api: apiName(model),
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.id,
|
||||
api: apiName(resolved),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) =>
|
||||
fromCatalogModel(withVariant(model, session.model?.variant), provider)
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
|
||||
fromCatalogModel(withVariant(model, session.model?.variant))
|
||||
|
||||
export const supported = (model: ModelV2.Info) =>
|
||||
model.api.type === "aisdk" &&
|
||||
@@ -130,6 +148,8 @@ export const locationLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
return Service.of({
|
||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||
@@ -140,7 +160,12 @@ export const locationLayer = Layer.effect(
|
||||
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
|
||||
(yield* catalog.model.available()).find(supported))
|
||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
|
||||
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
|
||||
return yield* fromCatalogModel(
|
||||
withVariant(selected, session.model?.variant),
|
||||
connection,
|
||||
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as PowerShell from "@/kilocode/shell/shell" // kilocode_change - PowerShell args
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
export * as Shell from "./shell"
|
||||
|
||||
import path from "path"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { readFile } from "fs/promises"
|
||||
import { statSync } from "fs"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { which } from "./util/which"
|
||||
import { PowerShell } from "./kilocode/powershell" // kilocode_change
|
||||
|
||||
const SIGKILL_TIMEOUT_MS = 200
|
||||
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
|
||||
@@ -48,7 +51,7 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole
|
||||
if (!opts?.exited?.()) {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
proc.kill("SIGTERM")
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
@@ -57,9 +60,13 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole
|
||||
}
|
||||
}
|
||||
|
||||
function stat(file: string) {
|
||||
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
||||
}
|
||||
|
||||
function full(file: string) {
|
||||
if (process.platform !== "win32") return file
|
||||
const shell = Filesystem.windowsPath(file)
|
||||
const shell = FSUtil.windowsPath(file)
|
||||
if (path.win32.dirname(shell) !== ".") {
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
|
||||
return shell
|
||||
@@ -77,13 +84,13 @@ function ok(file: string) {
|
||||
}
|
||||
|
||||
function rooted(file: string) {
|
||||
return path.isAbsolute(Filesystem.windowsPath(file))
|
||||
return path.isAbsolute(FSUtil.windowsPath(file))
|
||||
}
|
||||
|
||||
function resolve(file: string) {
|
||||
const shell = full(file)
|
||||
if (rooted(shell)) {
|
||||
if (Filesystem.stat(shell)?.isFile()) return shell
|
||||
if (stat(shell)?.isFile()) return shell
|
||||
return
|
||||
}
|
||||
return which(shell) ?? undefined
|
||||
@@ -100,7 +107,7 @@ function win() {
|
||||
}
|
||||
|
||||
async function unix() {
|
||||
const text = await Filesystem.readText("/etc/shells").catch(() => "")
|
||||
const text = await readFile("/etc/shells", "utf8").catch(() => "")
|
||||
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
|
||||
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
|
||||
}
|
||||
@@ -110,7 +117,7 @@ function select(file: string | undefined, opts?: { acceptable?: boolean }) {
|
||||
const shell = resolve(file)
|
||||
if (shell) return shell
|
||||
}
|
||||
if (process.platform === "win32") return win()[0]!
|
||||
if (process.platform === "win32") return win()[0]
|
||||
return fallback()
|
||||
}
|
||||
|
||||
@@ -120,7 +127,7 @@ export function gitbash() {
|
||||
const git = which("git")
|
||||
if (!git) return
|
||||
const file = path.join(git, "..", "..", "bin", "bash.exe")
|
||||
if (Filesystem.stat(file)?.size) return file
|
||||
if (stat(file)?.size) return file
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
@@ -131,7 +138,7 @@ function fallback() {
|
||||
}
|
||||
|
||||
export function name(file: string) {
|
||||
if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase()
|
||||
if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase()
|
||||
return path.basename(file).toLowerCase()
|
||||
}
|
||||
|
||||
@@ -189,28 +196,32 @@ export function args(file: string, command: string, cwd: string) {
|
||||
]
|
||||
}
|
||||
if (n === "cmd") return ["/c", command]
|
||||
if (ps(file)) return PowerShell.args(command) // kilocode_change - PowerShell args
|
||||
if (ps(file)) return PowerShell.args(command) // kilocode_change - preserve UTF-8 and script prologues
|
||||
return ["-c", command]
|
||||
}
|
||||
|
||||
const defaultPreferred = lazy(() => select(process.env.SHELL))
|
||||
const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
|
||||
let defaultPreferred: string | undefined
|
||||
let defaultAcceptable: string | undefined
|
||||
|
||||
export function preferred(configShell?: string) {
|
||||
if (configShell) return select(configShell)
|
||||
return defaultPreferred()
|
||||
defaultPreferred ??= select(process.env.SHELL)
|
||||
return defaultPreferred
|
||||
}
|
||||
preferred.reset = () => {
|
||||
defaultPreferred = undefined
|
||||
}
|
||||
preferred.reset = () => defaultPreferred.reset()
|
||||
|
||||
export function acceptable(configShell?: string) {
|
||||
if (configShell) return select(configShell, { acceptable: true })
|
||||
return defaultAcceptable()
|
||||
defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
|
||||
return defaultAcceptable
|
||||
}
|
||||
acceptable.reset = () => {
|
||||
defaultAcceptable = undefined
|
||||
}
|
||||
acceptable.reset = () => defaultAcceptable.reset()
|
||||
|
||||
export async function list(): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win() : await unix()
|
||||
return shells.filter((s) => resolve(s)).map(info)
|
||||
}
|
||||
|
||||
export * as Shell from "./shell"
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Layer, Option } from "effect"
|
||||
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
@@ -25,47 +25,29 @@ const it = testEffect(
|
||||
Layer.provideMerge(
|
||||
Layer.mock(Credential.Service)({
|
||||
all: () => Effect.succeed([]),
|
||||
list: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
describe("CatalogV2", () => {
|
||||
it.effect("projects Kilo organization routing from OAuth credentials", () => {
|
||||
const integrationID = Integration.ID.make("kilocode")
|
||||
const credential = new Credential.Stored({
|
||||
id: Credential.ID.create(),
|
||||
integrationID,
|
||||
label: "Organization",
|
||||
value: new Credential.OAuth({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
metadata: { accountID: "organization" },
|
||||
}),
|
||||
})
|
||||
const layer = Catalog.locationLayer.pipe(
|
||||
Layer.fresh,
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.mock(Credential.Service)({ all: () => Effect.succeed([credential]) }),
|
||||
),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
it.effect("publishes an updated event after catalog changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("kilocode"), () => {}))
|
||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("kilocode"))).toMatchObject({
|
||||
request: { body: { apiKey: "access", kilocodeOrganizationId: "organization" } },
|
||||
})
|
||||
}).pipe(Effect.provide(layer))
|
||||
})
|
||||
const events = yield* EventV2.Service
|
||||
const updated = yield* events
|
||||
.subscribe(Catalog.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
it.effect("projects active credentials without rebuilding catalog state", () => {
|
||||
yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
|
||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("derives availability from active credentials without changing provider state", () => {
|
||||
const integrationID = Integration.ID.make("test")
|
||||
const first = {
|
||||
id: Credential.ID.create(),
|
||||
@@ -87,6 +69,7 @@ describe("CatalogV2", () => {
|
||||
Layer.provideMerge(
|
||||
Layer.mock(Credential.Service)({
|
||||
all: () => Effect.sync(() => [active]),
|
||||
list: () => Effect.sync(() => [active]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -96,18 +79,44 @@ describe("CatalogV2", () => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
|
||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
|
||||
enabled: { via: "credential", credentialID: first.id },
|
||||
request: { body: { apiKey: "first", tenant: "one" } },
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
active = second
|
||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
|
||||
enabled: { via: "credential", credentialID: second.id },
|
||||
request: { body: { apiKey: "second", tenant: "two" } },
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
}).pipe(Effect.provide(layer))
|
||||
})
|
||||
|
||||
it.effect("projects environment connections without a catalog plugin", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.CATALOG_TEST_API_KEY
|
||||
process.env.CATALOG_TEST_API_KEY = "secret"
|
||||
return previous
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID: Integration.ID.make(providerID),
|
||||
method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
|
||||
}),
|
||||
)
|
||||
yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {}))
|
||||
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY
|
||||
else process.env.CATALOG_TEST_API_KEY = previous
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("normalizes provider baseURL into api url", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -326,9 +335,7 @@ describe("CatalogV2", () => {
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
})
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(1000)
|
||||
})
|
||||
@@ -350,9 +357,7 @@ describe("CatalogV2", () => {
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
const models = (catalog: Catalog.Editor) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
})
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, old, (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(1000)
|
||||
})
|
||||
@@ -383,12 +388,10 @@ describe("CatalogV2", () => {
|
||||
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(disabledProvider, (provider) => {
|
||||
provider.enabled = false
|
||||
provider.disabled = true
|
||||
})
|
||||
catalog.model.update(disabledProvider, disabledModel, () => {})
|
||||
catalog.provider.update(enabledProvider, (provider) => {
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
})
|
||||
catalog.provider.update(enabledProvider, () => {})
|
||||
catalog.model.update(enabledProvider, fallbackModel, () => {})
|
||||
catalog.model.default.set(disabledProvider, disabledModel)
|
||||
})
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Effect, Option, Schema } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it } from "../plugin/provider-helper"
|
||||
import { it, withEnv } from "../plugin/provider-helper"
|
||||
|
||||
function request(headers: Record<string, string>, variant?: string) {
|
||||
return {
|
||||
@@ -21,6 +22,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("partitions existing model variant bodies without changing config shape", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.opencode
|
||||
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
||||
@@ -59,6 +61,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -80,6 +83,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("uses the effective provider package across layered config", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.opencode
|
||||
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
||||
@@ -118,6 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -131,118 +136,126 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.effect("loads configured providers and applies later model overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("custom")
|
||||
const modelID = ModelV2.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/first",
|
||||
providers: {
|
||||
custom: {
|
||||
name: "Configured",
|
||||
env: ["CUSTOM_API_KEY"],
|
||||
api: { type: "native", settings: {} },
|
||||
request: request({ first: "first", shared: "first" }),
|
||||
models: {
|
||||
chat: {
|
||||
name: "First",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
disabled: true,
|
||||
limit: { context: 100, output: 50 },
|
||||
cost: { input: 1, output: 2 },
|
||||
request: request({ first: "first", shared: "first" }, "retained"),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { first: "first", shared: "first" },
|
||||
},
|
||||
],
|
||||
withEnv({ CUSTOM_API_KEY: "secret" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("custom")
|
||||
const modelID = ModelV2.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/first",
|
||||
providers: {
|
||||
custom: {
|
||||
name: "Configured",
|
||||
env: ["CUSTOM_API_KEY"],
|
||||
api: { type: "native", settings: {} },
|
||||
request: request({ first: "first", shared: "first" }),
|
||||
models: {
|
||||
chat: {
|
||||
name: "First",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
disabled: true,
|
||||
limit: { context: 100, output: 50 },
|
||||
cost: { input: 1, output: 2 },
|
||||
request: request({ first: "first", shared: "first" }, "retained"),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { first: "first", shared: "first" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/default",
|
||||
providers: {
|
||||
custom: {
|
||||
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
models: {
|
||||
default: {
|
||||
name: "Default",
|
||||
},
|
||||
chat: {
|
||||
api: { id: "api-chat" },
|
||||
name: "Last",
|
||||
limit: { output: 75 },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { last: "last", shared: "last" },
|
||||
},
|
||||
{
|
||||
id: "slow",
|
||||
headers: { slow: "slow" },
|
||||
},
|
||||
],
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/default",
|
||||
providers: {
|
||||
custom: {
|
||||
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
models: {
|
||||
default: {
|
||||
name: "Default",
|
||||
},
|
||||
chat: {
|
||||
api: { id: "api-chat" },
|
||||
name: "Last",
|
||||
limit: { output: 75 },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { last: "last", shared: "last" },
|
||||
},
|
||||
{
|
||||
id: "slow",
|
||||
headers: { slow: "slow" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: { name: "Renamed" },
|
||||
},
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: { name: "Renamed" },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
]),
|
||||
})
|
||||
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
),
|
||||
})
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
})
|
||||
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
|
||||
expect(provider.name).toBe("Renamed")
|
||||
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
|
||||
expect(provider.enabled).toEqual({ via: "custom", data: {} })
|
||||
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
|
||||
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(model.name).toBe("Last")
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
|
||||
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.request.variant).toBe("retained")
|
||||
expect(model.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("fast"),
|
||||
ModelV2.VariantID.make("slow"),
|
||||
])
|
||||
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
|
||||
}),
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
|
||||
expect(provider.name).toBe("Renamed")
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
|
||||
type: "env",
|
||||
names: ["CUSTOM_API_KEY"],
|
||||
})
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
|
||||
expect(provider.disabled).toBeUndefined()
|
||||
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
|
||||
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(model.name).toBe("Last")
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
|
||||
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.request.variant).toBe("retained")
|
||||
expect(model.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("fast"),
|
||||
ModelV2.VariantID.make("slow"),
|
||||
])
|
||||
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { IntegrationConnection } from "@opencode-ai/core/integration/connection"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { it } from "./lib/effect"
|
||||
@@ -25,7 +24,7 @@ function connectionLayer(
|
||||
}>,
|
||||
) {
|
||||
return Integration.locationLayer.pipe(
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mock(Credential.Service)({
|
||||
create: (input) =>
|
||||
@@ -103,7 +102,7 @@ describe("Integration", () => {
|
||||
.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize,
|
||||
}),
|
||||
)
|
||||
@@ -117,7 +116,7 @@ describe("Integration", () => {
|
||||
])
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
|
||||
authorize,
|
||||
})
|
||||
})
|
||||
@@ -140,15 +139,20 @@ describe("Integration", () => {
|
||||
}> = []
|
||||
return Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const events = yield* EventV2.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.KeyMethod({ type: "key", label: "API key" }),
|
||||
method: { type: "key", label: "API key" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* events
|
||||
.subscribe(Integration.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* integrations.connect.key({
|
||||
yield* integrations.connection.key({
|
||||
integrationID,
|
||||
key: "secret",
|
||||
label: "Work",
|
||||
@@ -161,6 +165,7 @@ describe("Integration", () => {
|
||||
value: new Credential.Key({ type: "key", key: "secret" }),
|
||||
},
|
||||
])
|
||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||
}).pipe(Effect.provide(connectionLayer(created)))
|
||||
})
|
||||
|
||||
@@ -177,7 +182,7 @@ describe("Integration", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize: () =>
|
||||
Effect.succeed({
|
||||
mode: "code" as const,
|
||||
@@ -198,7 +203,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({
|
||||
const attempt = yield* integrations.connection.oauth({
|
||||
integrationID,
|
||||
methodID,
|
||||
inputs: {},
|
||||
@@ -236,7 +241,7 @@ describe("Integration", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize: () =>
|
||||
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
|
||||
Effect.as({
|
||||
@@ -249,7 +254,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
|
||||
Integration.CodeRequiredError,
|
||||
)
|
||||
@@ -273,7 +278,7 @@ describe("Integration", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: () =>
|
||||
Effect.succeed({
|
||||
mode: "auto" as const,
|
||||
@@ -286,7 +291,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
|
||||
status: "complete",
|
||||
@@ -310,7 +315,7 @@ describe("Integration", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: () =>
|
||||
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
|
||||
Effect.as({
|
||||
@@ -323,7 +328,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
|
||||
yield* TestClock.adjust(Duration.minutes(10))
|
||||
yield* Effect.yieldNow
|
||||
@@ -373,23 +378,28 @@ describe("Integration", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.EnvMethod({
|
||||
method: {
|
||||
type: "env",
|
||||
names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Stored credentials and detected env vars appear as connections.
|
||||
expect((yield* integrations.get(integrationID))?.connections).toEqual([
|
||||
new IntegrationConnection.CredentialInfo({ type: "credential", id: rows[0]!.id, label: "Work" }),
|
||||
new IntegrationConnection.CredentialInfo({
|
||||
{ type: "credential", id: rows[0]!.id, label: "Work" },
|
||||
{
|
||||
type: "credential",
|
||||
id: rows[1]!.id,
|
||||
label: "Personal",
|
||||
}),
|
||||
new IntegrationConnection.EnvInfo({ type: "env", name: "INTEGRATION_TEST_ACME_KEY" }),
|
||||
},
|
||||
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
|
||||
])
|
||||
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
|
||||
type: "credential",
|
||||
id: rows[1]!.id,
|
||||
label: "Personal",
|
||||
})
|
||||
}).pipe(Effect.provide(projectionLayer)),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("SessionRunnerModel Kilo credentials", () => {
|
||||
it.effect("maps OAuth account IDs to Kilo organization routing", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = new ModelV2.Info({
|
||||
id: ModelV2.ID.make("test-model"),
|
||||
providerID: ProviderV2.ID.make("kilo"),
|
||||
name: "Test model",
|
||||
api: {
|
||||
id: ModelV2.ID.make("api-test-model"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.kilo.ai/openrouter",
|
||||
},
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
request: { headers: {}, body: {}, generation: {}, options: {} },
|
||||
variants: [],
|
||||
time: { released: DateTime.makeUnsafe(0) },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
const credential = new Credential.Stored({
|
||||
id: Credential.ID.create(),
|
||||
integrationID: Integration.ID.make("kilo"),
|
||||
label: "Work",
|
||||
value: new Credential.OAuth({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
refresh: "refresh",
|
||||
access: "access",
|
||||
expires: 1,
|
||||
metadata: { accountID: "org-enterprise" },
|
||||
}),
|
||||
})
|
||||
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model,
|
||||
{ type: "credential", id: credential.id, label: credential.label },
|
||||
credential,
|
||||
)
|
||||
|
||||
expect(resolved.route.defaults.http?.body).toMatchObject({ kilocodeOrganizationId: "org-enterprise" })
|
||||
expect(resolved.route.defaults.http?.body).not.toHaveProperty("accountID")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -28,8 +28,10 @@ const connections = Credential.layer.pipe(
|
||||
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
|
||||
Layer.provide(events),
|
||||
)
|
||||
const catalog = Catalog.layer.pipe(Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections)))
|
||||
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
|
||||
const catalog = Catalog.layer.pipe(
|
||||
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
|
||||
)
|
||||
const layer = Layer.mergeAll(
|
||||
catalog.pipe(Layer.provide(connections)),
|
||||
integrations,
|
||||
@@ -61,11 +63,11 @@ describe("ModelsDevPlugin", () => {
|
||||
id: Integration.ID.make("acme"),
|
||||
name: "Acme",
|
||||
methods: [
|
||||
new Integration.KeyMethod({ type: "key" }),
|
||||
new Integration.EnvMethod({
|
||||
{ type: "key" },
|
||||
{
|
||||
type: "env",
|
||||
names: ["ACME_API_KEY"],
|
||||
}),
|
||||
},
|
||||
],
|
||||
connections: [],
|
||||
}),
|
||||
|
||||
@@ -53,6 +53,7 @@ const integrations = Integration.locationLayer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mock(Credential.Service)({
|
||||
create: () => Effect.die("unexpected credential creation"),
|
||||
all: () => Effect.succeed([]),
|
||||
list: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
|
||||
@@ -8,6 +9,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { expectPluginRegistered, it, provider } from "./provider-helper"
|
||||
|
||||
describe("LLMGatewayPlugin", () => {
|
||||
const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* plugin.add({
|
||||
...LLMGatewayPlugin,
|
||||
effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() =>
|
||||
expectPluginRegistered(
|
||||
@@ -21,31 +30,29 @@ describe("LLMGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(LLMGatewayPlugin)
|
||||
yield* add(plugin)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.update((editor) => {
|
||||
editor.update(Integration.ID.make("llmgateway"), () => {})
|
||||
editor.update(Integration.ID.make("openrouter"), () => {})
|
||||
})
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const llmgateway = provider("llmgateway", {
|
||||
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(llmgateway.id, (draft) => {
|
||||
draft.enabled = llmgateway.enabled
|
||||
draft.api = llmgateway.api
|
||||
draft.request = llmgateway.request
|
||||
})
|
||||
const openrouter = provider("openrouter", {
|
||||
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
|
||||
})
|
||||
catalog.provider.update(openrouter.id, (draft) => {
|
||||
draft.enabled = openrouter.enabled
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://kilo.ai/",
|
||||
"X-Title": "Kilo Code",
|
||||
"X-Source": "kilo",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-Source": "opencode",
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
|
||||
}),
|
||||
@@ -55,7 +62,7 @@ describe("LLMGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(LLMGatewayPlugin)
|
||||
yield* add(plugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("llmgateway", {
|
||||
@@ -66,7 +73,7 @@ describe("LLMGatewayPlugin", () => {
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -21,16 +21,16 @@ describe("OpenAIPlugin", () => {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* add(plugin, yield* Integration.Service)
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
|
||||
new Integration.OAuthMethod({
|
||||
{
|
||||
id: Integration.MethodID.make("chatgpt-browser"),
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (browser)",
|
||||
}),
|
||||
new Integration.OAuthMethod({
|
||||
},
|
||||
{
|
||||
id: Integration.MethodID.make("chatgpt-headless"),
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (headless)",
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DateTime, Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
@@ -18,13 +19,18 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
|
||||
const pluginWithIntegrations = (integrations: Integration.Interface) => ({
|
||||
...OpencodePlugin,
|
||||
effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
|
||||
})
|
||||
|
||||
describe("OpencodePlugin", () => {
|
||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
@@ -45,7 +51,7 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
@@ -66,7 +72,7 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
@@ -87,7 +93,7 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
@@ -108,13 +114,18 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* plugin.add(pluginWithIntegrations(integrations))
|
||||
yield* integrations.update((editor) => {
|
||||
editor.method.update({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
|
||||
})
|
||||
})
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.env = [...item.env]
|
||||
})
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
@@ -131,7 +142,7 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", {
|
||||
@@ -154,37 +165,12 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses auth-enabled providers as credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", {
|
||||
enabled: { via: "credential", credentialID: Credential.ID.make("credential") },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.enabled = item.enabled
|
||||
})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("ignores non-opencode providers and models", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("openai")
|
||||
|
||||
@@ -365,6 +365,18 @@ describe("ProjectCopy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh ignores existing directories that are no longer git checkouts", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh with no roots is a no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
@@ -24,4 +24,9 @@ describe("Pty.Info", () => {
|
||||
test("rejects a negative pid", () => {
|
||||
expect(() => Schema.decodeUnknownSync(Pty.Info)(sample(-1))).toThrow()
|
||||
})
|
||||
|
||||
test("accepts an exit code for retained exited sessions", () => {
|
||||
const info = Schema.decodeUnknownSync(Pty.Info)({ ...sample(48012), status: "exited", exitCode: 4 })
|
||||
expect(info.exitCode).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { handlePtyInput } from "@opencode-ai/core/pty/input"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("pty websocket input", () => {
|
||||
it.effect("does not forward invalid binary frames to the PTY handler", () =>
|
||||
Effect.gen(function* () {
|
||||
const messages: Array<string | ArrayBuffer> = []
|
||||
const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
|
||||
|
||||
yield* handlePtyInput(handler, "ready")
|
||||
yield* handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd]))
|
||||
yield* handlePtyInput(handler, new TextEncoder().encode("hello"))
|
||||
|
||||
expect(messages).toEqual(["ready", "hello"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
|
||||
|
||||
describe("pty protocol", () => {
|
||||
test("drops invalid binary input frames and decodes valid ones", () => {
|
||||
expect(PtyProtocol.decodeInput("ready")).toBe("ready")
|
||||
expect(PtyProtocol.decodeInput(new Uint8Array([0xff, 0xfe, 0xfd]))).toBeUndefined()
|
||||
expect(PtyProtocol.decodeInput(new TextEncoder().encode("hello"))).toBe("hello")
|
||||
expect(PtyProtocol.decodeInput(new TextEncoder().encode("hello").buffer)).toBe("hello")
|
||||
})
|
||||
|
||||
test("encodes the cursor as a 0x00-prefixed JSON control frame", () => {
|
||||
const frame = PtyProtocol.metaFrame(42)
|
||||
expect(frame[0]).toBe(0)
|
||||
expect(JSON.parse(new TextDecoder().decode(frame.subarray(1)))).toEqual({ cursor: 42 })
|
||||
})
|
||||
|
||||
test("splits replay into bounded frames", () => {
|
||||
expect(PtyProtocol.chunks("")).toEqual([])
|
||||
expect(PtyProtocol.chunks("abc")).toEqual(["abc"])
|
||||
const big = "x".repeat(PtyProtocol.REPLAY_CHUNK + 1)
|
||||
const frames = PtyProtocol.chunks(big)
|
||||
expect(frames.length).toBe(2)
|
||||
expect(frames[0].length).toBe(PtyProtocol.REPLAY_CHUNK)
|
||||
expect(frames.join("")).toBe(big)
|
||||
})
|
||||
})
|
||||
@@ -1,110 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Queue } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
type Socket = Parameters<Pty.Interface["connect"]>[1]
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
|
||||
)
|
||||
const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
|
||||
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
const createPty = Effect.fn("PtyOutputIsolationTest.createPty")(function* (command: string) {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* Effect.acquireRelease(
|
||||
pty.create({ command, args: [], cwd: "/tmp", env: { TERM: "xterm-256color", KILO_TERMINAL: "1" } }),
|
||||
(info) => pty.remove(info.id).pipe(Effect.ignore),
|
||||
)
|
||||
})
|
||||
|
||||
const decodeOutput = (data: string | Uint8Array | ArrayBuffer) =>
|
||||
typeof data === "string"
|
||||
? data
|
||||
: Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data)).toString("utf8")
|
||||
|
||||
const makeSocket = Effect.fn("PtyOutputIsolationTest.makeSocket")(function* (data: unknown) {
|
||||
const output = yield* Queue.unbounded<string>()
|
||||
const socket: Socket = {
|
||||
readyState: 1,
|
||||
data,
|
||||
send: (data) => Queue.offerUnsafe(output, decodeOutput(data)),
|
||||
close: () => {},
|
||||
}
|
||||
return { socket, output }
|
||||
})
|
||||
|
||||
const waitForOutput = (output: Queue.Queue<string>, text: string, duration: Duration.Input = "5 seconds") =>
|
||||
Effect.gen(function* () {
|
||||
let received = ""
|
||||
while (!received.includes(text)) received += yield* Queue.take(output)
|
||||
return received
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration,
|
||||
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
|
||||
}),
|
||||
)
|
||||
|
||||
describe("pty output isolation", () => {
|
||||
ptyTest("does not leak output when websocket objects are reused", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const a = yield* createPty("cat")
|
||||
const b = yield* createPty("cat")
|
||||
const shared = yield* makeSocket({ events: { connection: "a" } })
|
||||
const outB = yield* Queue.unbounded<string>()
|
||||
|
||||
yield* pty.connect(a.id, shared.socket)
|
||||
shared.socket.data = { events: { connection: "b" } }
|
||||
shared.socket.send = (data) => Queue.offerUnsafe(outB, decodeOutput(data))
|
||||
yield* pty.connect(b.id, shared.socket)
|
||||
yield* pty.write(a.id, "AAA\n")
|
||||
|
||||
const verify = yield* makeSocket({ events: { connection: "verify-a" } })
|
||||
yield* pty.connect(a.id, verify.socket)
|
||||
expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA")
|
||||
expect(yield* waitForOutput(outB, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" })
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("does not leak output when Bun recycles websocket objects before re-connect", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* createPty("cat")
|
||||
const first = yield* makeSocket({ events: { connection: "a" } })
|
||||
const recycled = yield* Queue.unbounded<string>()
|
||||
|
||||
yield* pty.connect(info.id, first.socket)
|
||||
first.socket.data = { events: { connection: "b" } }
|
||||
first.socket.send = (data) => Queue.offerUnsafe(recycled, decodeOutput(data))
|
||||
yield* pty.write(info.id, "AAA\n")
|
||||
|
||||
const verify = yield* makeSocket({ events: { connection: "verify" } })
|
||||
yield* pty.connect(info.id, verify.socket)
|
||||
expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA")
|
||||
expect(yield* waitForOutput(recycled, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" })
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("treats in-place socket data mutation as the same connection", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* createPty("cat")
|
||||
const data = { connId: 1 }
|
||||
const socket = yield* makeSocket(data)
|
||||
|
||||
yield* pty.connect(info.id, socket.socket)
|
||||
data.connId = 2
|
||||
yield* pty.write(info.id, "AAA\n")
|
||||
|
||||
expect(yield* waitForOutput(socket.output, "AAA")).toContain("AAA")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer, Queue } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
@@ -14,7 +15,14 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
|
||||
)
|
||||
const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
|
||||
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const it = testEffect(
|
||||
Pty.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
),
|
||||
)
|
||||
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
|
||||
@@ -56,36 +64,176 @@ const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number)
|
||||
}),
|
||||
)
|
||||
|
||||
const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* (id: PtyID, cursor?: number) {
|
||||
const pty = yield* Pty.Service
|
||||
const output = yield* Queue.unbounded<string>()
|
||||
const ended = yield* Deferred.make<{ exitCode?: number }>()
|
||||
const attachment = yield* pty.attach(id, {
|
||||
cursor,
|
||||
onData: (chunk) => Queue.offerUnsafe(output, chunk),
|
||||
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
|
||||
})
|
||||
attachment.activate()
|
||||
return { attachment, output, ended }
|
||||
})
|
||||
|
||||
const waitForOutput = (output: Queue.Queue<string>, text: string) =>
|
||||
Effect.gen(function* () {
|
||||
let received = ""
|
||||
while (!received.includes(text)) received += yield* Queue.take(output)
|
||||
return received
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
|
||||
}),
|
||||
)
|
||||
|
||||
describe("pty", () => {
|
||||
it.live("returns typed not found errors for missing sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const id = "pty_missing" as PtyID
|
||||
let closed = false
|
||||
const socket = { readyState: 1, send: () => {}, close: () => void (closed = true) }
|
||||
|
||||
for (const result of [
|
||||
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
|
||||
yield* pty.update(id, { title: "missing" }).pipe(Effect.asVoid, Effect.exit),
|
||||
yield* pty.remove(id).pipe(Effect.exit),
|
||||
yield* pty.resize(id, 80, 24).pipe(Effect.exit),
|
||||
yield* pty.write(id, "input").pipe(Effect.exit),
|
||||
yield* pty.connect(id, socket).pipe(Effect.asVoid, Effect.exit),
|
||||
yield* pty.attach(id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.asVoid, Effect.exit),
|
||||
]) {
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
if (Exit.isFailure(result))
|
||||
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
|
||||
}
|
||||
expect(closed).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("publishes created, exited, deleted in order for a short-lived process", () =>
|
||||
ptyTest("retains exited sessions until removed", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const events = yield* subscribePtyEvents()
|
||||
const info = yield* createPty("/usr/bin/env", ["sh", "-c", "sleep 0.1"])
|
||||
const info = yield* createPty("/usr/bin/env", ["sh", "-c", "exit 3"])
|
||||
|
||||
expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"])
|
||||
expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"])
|
||||
const exited = yield* pty.get(info.id)
|
||||
expect(exited.status).toBe("exited")
|
||||
expect(exited.exitCode).toBe(3)
|
||||
|
||||
yield* pty.remove(info.id)
|
||||
expect(yield* waitForEvents(events, info.id, 1)).toEqual(["deleted"])
|
||||
const missing = yield* pty.get(info.id).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(missing)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("replays buffered output and streams live output to attachments", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* createPty("cat")
|
||||
yield* pty.write(info.id, "AAA\n")
|
||||
|
||||
const first = yield* attachCollecting(info.id)
|
||||
expect(yield* waitForOutput(first.output, "AAA")).toContain("AAA")
|
||||
|
||||
first.attachment.write("BBB\n")
|
||||
yield* waitForOutput(first.output, "BBB")
|
||||
|
||||
// A later attachment replays everything already buffered.
|
||||
const replayed = yield* attachCollecting(info.id)
|
||||
expect(replayed.attachment.replay).toContain("AAA")
|
||||
expect(replayed.attachment.replay).toContain("BBB")
|
||||
expect(replayed.attachment.cursor).toBeGreaterThan(0)
|
||||
|
||||
// Tail attachments skip the buffer and only see subsequent output.
|
||||
const tail = yield* attachCollecting(info.id, -1)
|
||||
expect(tail.attachment.replay).toBe("")
|
||||
expect(tail.attachment.cursor).toBe(replayed.attachment.cursor)
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("stops delivering output after detach", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* createPty("cat")
|
||||
const attached = yield* attachCollecting(info.id, -1)
|
||||
|
||||
attached.attachment.detach()
|
||||
yield* pty.write(info.id, "AAA\n")
|
||||
|
||||
const verify = yield* attachCollecting(info.id)
|
||||
yield* waitForOutput(verify.output, "AAA")
|
||||
const leaked = yield* Queue.poll(attached.output)
|
||||
expect(leaked._tag).toBe("None")
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("isolates output between sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const a = yield* createPty("cat")
|
||||
const b = yield* createPty("cat")
|
||||
const attachedA = yield* attachCollecting(a.id)
|
||||
const attachedB = yield* attachCollecting(b.id)
|
||||
|
||||
yield* pty.write(a.id, "AAA\n")
|
||||
yield* waitForOutput(attachedA.output, "AAA")
|
||||
|
||||
const leaked = yield* Queue.poll(attachedB.output)
|
||||
expect(leaked._tag).toBe("None")
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("notifies attachments with the exit code and rejects attach after exit", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const events = yield* subscribePtyEvents()
|
||||
const info = yield* createPty("cat")
|
||||
const attached = yield* attachCollecting(info.id)
|
||||
|
||||
yield* pty.write(info.id, "\u0004")
|
||||
expect(yield* Deferred.await(attached.ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 0 })
|
||||
yield* waitForEvents(events, info.id, 2)
|
||||
|
||||
const result = yield* pty.attach(info.id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
if (Exit.isFailure(result))
|
||||
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
|
||||
const configuredIt = testEffect(
|
||||
Pty.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mock(Config.Service)({
|
||||
entries: () =>
|
||||
Effect.succeed(
|
||||
configuredShell
|
||||
? [new Config.Document({ type: "document", info: new Config.Info({ shell: configuredShell }) })]
|
||||
: [],
|
||||
),
|
||||
}),
|
||||
),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
),
|
||||
)
|
||||
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
|
||||
|
||||
describe("pty create defaults", () => {
|
||||
configuredTest("defaults command, login args, and cwd from config and location", () =>
|
||||
Effect.gen(function* () {
|
||||
if (!configuredShell) return
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* Effect.acquireRelease(pty.create({ title: "configured" }), (created) =>
|
||||
pty.remove(created.id).pipe(Effect.ignore),
|
||||
)
|
||||
expect(info.command).toBe(configuredShell)
|
||||
expect(info.args).toEqual(["-l"])
|
||||
expect(info.cwd).toBe("/tmp")
|
||||
expect(info.title).toBe("configured")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,8 @@ import { LLM } from "@opencode-ai/llm"
|
||||
import { LLMClient } from "@opencode-ai/llm/route"
|
||||
import { ConfigProvider, DateTime, Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
@@ -45,8 +47,6 @@ const provider = (api: ProviderV2.Info["api"]) =>
|
||||
new ProviderV2.Info({
|
||||
id: ProviderV2.ID.make("test-provider"),
|
||||
name: "Test provider",
|
||||
enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" },
|
||||
env: ["TEST_PROVIDER_API_KEY"],
|
||||
api,
|
||||
request: { headers: {}, body: {} },
|
||||
})
|
||||
@@ -247,7 +247,7 @@ describe("SessionRunnerModel", () => {
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {}, generation: {}, options: {} },
|
||||
}),
|
||||
provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
{ type: "env", name: "TEST_PROVIDER_API_KEY" },
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const headers = yield* resolved.route.auth
|
||||
@@ -266,6 +266,35 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prefers stored credentials over configured auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = new Credential.Stored({
|
||||
id: Credential.ID.create(),
|
||||
integrationID: Integration.ID.make("test-provider"),
|
||||
label: "Work",
|
||||
value: new Credential.Key({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }),
|
||||
})
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
new ModelV2.Info({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: { apiKey: "configured-secret" }, generation: {}, options: {} },
|
||||
}),
|
||||
{ type: "credential", id: credential.id, label: credential.label },
|
||||
credential,
|
||||
)
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://openai.example/v1/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(headers.authorization).toBe("Bearer stored-secret")
|
||||
expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects catalog APIs without a native route", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
||||
|
||||
@@ -704,7 +704,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* events.publish(SessionEvent.Moved, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
|
||||
location: { directory: AbsolutePath.make("/moved") },
|
||||
})
|
||||
expect(
|
||||
yield* db
|
||||
@@ -762,7 +762,7 @@ describe("SessionRunnerLLM", () => {
|
||||
.publish(SessionEvent.Moved, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
|
||||
location: { directory: AbsolutePath.make("/moved") },
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
})
|
||||
@@ -1461,6 +1461,7 @@ describe("SessionRunnerLLM", () => {
|
||||
})
|
||||
|
||||
requests.length = 0
|
||||
executions.length = 0
|
||||
responses = [
|
||||
fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents,
|
||||
fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents,
|
||||
@@ -3177,7 +3178,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails after the bounded number of local tool continuation steps", () =>
|
||||
it.effect("continues past 25 local tool steps when the agent has no step limit", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
@@ -3188,62 +3189,10 @@ describe("SessionRunnerLLM", () => {
|
||||
executions.length = 0
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
responses = Array.from({ length: 25 }, (_, index) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
])
|
||||
|
||||
const failure = yield* session.resume(sessionID).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError", sessionID, limit: 25 })
|
||||
expect(requests).toHaveLength(25)
|
||||
expect(executions).toHaveLength(25)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not restart a capped tool loop for a coalesced stale wake", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const coordinator = yield* SessionRunCoordinator.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = Array.from({ length: 25 }, (_, index) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: `call-capped-${index}`, name: "echo", input: { text: `${index}` } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
])
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* coordinator.wake(sessionID)
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError" })
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(requests).toHaveLength(25)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts a terminal response on the final bounded provider turn", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
...Array.from({ length: 24 }, (_, index) => [
|
||||
...Array.from({ length: 25 }, (_, index) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: `call-terminal-${index}`, name: "echo", input: { text: `${index}` } }),
|
||||
LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
]),
|
||||
@@ -3256,7 +3205,56 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(25)
|
||||
expect(requests).toHaveLength(26)
|
||||
expect(executions).toHaveLength(25)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forces a text response on an agent's configured final step", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.update((editor) =>
|
||||
editor.update(AgentV2.ID.make("build"), (agent) => {
|
||||
agent.steps = 2
|
||||
}),
|
||||
)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
executions.length = 0
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||
expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
|
||||
expect(requests[1]?.tools).toEqual([])
|
||||
expect(requests[1]?.messages.at(-1)).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
|
||||
})
|
||||
expect(executions).toEqual(["done"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Finish at the limit" },
|
||||
{ type: "assistant", content: [{ type: "tool", id: "call-terminal", state: { status: "completed" } }] },
|
||||
{ type: "assistant", content: [{ type: "tool", id: "call-forbidden", state: { status: "error" } }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
|
||||
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
|
||||
@@ -54,6 +54,15 @@ describe("shell", () => {
|
||||
expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")
|
||||
})
|
||||
|
||||
test("builds command args per shell family", () => {
|
||||
expect(Shell.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
|
||||
expect(Shell.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
|
||||
const zsh = Shell.args("/bin/zsh", "echo hi", "/tmp")
|
||||
expect(zsh[0]).toBe("-l")
|
||||
expect(zsh[1]).toBe("-c")
|
||||
expect(zsh.at(-1)).toBe("/tmp")
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("rejects blacklisted shells case-insensitively", async () => {
|
||||
await withShell("NU.EXE", async () => {
|
||||
@@ -64,7 +73,7 @@ describe("shell", () => {
|
||||
test("normalizes Git Bash shell paths from env", async () => {
|
||||
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
|
||||
await withShell(shell, async () => {
|
||||
expect(Shell.preferred()).toBe(Filesystem.windowsPath(shell))
|
||||
expect(Shell.preferred()).toBe(FSUtil.windowsPath(shell))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -10,8 +10,8 @@
|
||||
"url": "git+https://github.com/Kilo-Org/kilocode.git",
|
||||
"directory": "packages/http-recorder"
|
||||
},
|
||||
"homepage": "https://github.com/Kilo-Org/kilocode/tree/main/packages/http-recorder",
|
||||
"bugs": "https://github.com/Kilo-Org/kilocode/issues",
|
||||
"homepage": "https://github.com/anomalyco/opencode/tree/dev/packages/http-recorder",
|
||||
"bugs": "https://github.com/anomalyco/opencode/issues",
|
||||
"keywords": [
|
||||
"effect",
|
||||
"http",
|
||||
@@ -28,10 +28,10 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"build": "bun ./script/build.ts",
|
||||
"verify:package": "bun ./script/verify-package.ts"
|
||||
"verify:package": "bun ./script/verify-package.ts",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -8,8 +8,8 @@
|
||||
"scripts": {
|
||||
"setup:recording-env": "bun run script/setup-recording-env.ts",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -412,6 +412,9 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
stopSequences: generation?.stop,
|
||||
},
|
||||
toolConfig,
|
||||
// Converse's base inferenceConfig has no topK; Anthropic/Nova accept it
|
||||
// as a model-specific field, so it goes through additionalModelRequestFields.
|
||||
additionalModelRequestFields: generation?.topK === undefined ? undefined : { top_k: generation.topK },
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -83,6 +83,26 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLM.updateRequest(baseRequest, { generation: { maxTokens: 64, temperature: 0, topK: 40 } }),
|
||||
)
|
||||
|
||||
// Converse's inferenceConfig has no topK; Anthropic/Nova read it from
|
||||
// additionalModelRequestFields as top_k.
|
||||
expect(prepared.body.inferenceConfig).toEqual({ maxTokens: 64, temperature: 0 })
|
||||
expect(prepared.body.additionalModelRequestFields).toEqual({ top_k: 40 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits additionalModelRequestFields when topK is unset", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(baseRequest)
|
||||
expect(prepared.body.additionalModelRequestFields).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
|
||||
@@ -80,10 +80,11 @@ export class Subscription {
|
||||
async replayMessage(message: SessionMessageResponse) {
|
||||
if (message.info.role !== "assistant" && message.info.role !== "user") return
|
||||
|
||||
const cwd = message.info.role === "assistant" ? message.info.path?.cwd : undefined
|
||||
for (const part of message.parts) {
|
||||
await this.recordFetchedPart(message.info.sessionID, message, part)
|
||||
if (part.type === "tool") {
|
||||
await this.handleToolPart(message.info.sessionID, part)
|
||||
await this.handleToolPart(message.info.sessionID, part, cwd ?? process.cwd())
|
||||
continue
|
||||
}
|
||||
await this.replayContentPart(message, part)
|
||||
@@ -146,7 +147,7 @@ export class Subscription {
|
||||
}),
|
||||
)
|
||||
if (part.type === "tool") {
|
||||
await this.handleToolPart(session.id, part)
|
||||
await this.handleToolPart(session.id, part, session.cwd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,8 +232,8 @@ export class Subscription {
|
||||
)
|
||||
}
|
||||
|
||||
private async handleToolPart(sessionId: string, part: ToolPart) {
|
||||
await this.toolStart(sessionId, part)
|
||||
private async handleToolPart(sessionId: string, part: ToolPart, cwd: string) {
|
||||
await this.toolStart(sessionId, part, cwd)
|
||||
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
@@ -240,7 +241,7 @@ export class Subscription {
|
||||
return
|
||||
|
||||
case "running":
|
||||
await this.runningTool(sessionId, part)
|
||||
await this.runningTool(sessionId, part, cwd)
|
||||
return
|
||||
|
||||
case "completed":
|
||||
@@ -253,6 +254,7 @@ export class Subscription {
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -268,6 +270,7 @@ export class Subscription {
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -275,7 +278,7 @@ export class Subscription {
|
||||
}
|
||||
}
|
||||
|
||||
private async runningTool(sessionId: string, part: ToolPart) {
|
||||
private async runningTool(sessionId: string, part: ToolPart, cwd: string) {
|
||||
if (part.state.status !== "running") return
|
||||
|
||||
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
|
||||
@@ -289,6 +292,7 @@ export class Subscription {
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -306,12 +310,13 @@ export class Subscription {
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
output,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async toolStart(sessionId: string, part: ToolPart) {
|
||||
private async toolStart(sessionId: string, part: ToolPart, cwd: string) {
|
||||
if (this.toolStarts.has(part.callID)) return
|
||||
this.toolStarts.add(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
@@ -322,6 +327,7 @@ export class Subscription {
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isAbsolute, resolve } from "path"
|
||||
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
|
||||
|
||||
export type ToolInput = Record<string, unknown>
|
||||
@@ -69,10 +70,16 @@ export function toToolKind(toolName: string): ToolKind {
|
||||
}
|
||||
}
|
||||
|
||||
export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
|
||||
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
case "shell": {
|
||||
const workdir = shellWorkdir(input, cwd)
|
||||
return workdir ? [{ path: workdir }] : []
|
||||
}
|
||||
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
@@ -88,10 +95,6 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
|
||||
case "context7_get_library_docs":
|
||||
return locationFrom(input.path)
|
||||
|
||||
case "bash":
|
||||
case "shell":
|
||||
return []
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
@@ -122,14 +125,15 @@ export function pendingToolCall(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: { readonly input: ToolInput; readonly title?: string }
|
||||
readonly cwd?: string
|
||||
}): ToolCall {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
title: input.state.title || input.toolName,
|
||||
title: toolTitle(input.toolName, input.state.input, input.state.title),
|
||||
kind: toToolKind(input.toolName),
|
||||
status: "pending",
|
||||
locations: toLocations(input.toolName, input.state.input),
|
||||
rawInput: input.state.input,
|
||||
locations: toLocations(input.toolName, input.state.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +142,7 @@ export function runningToolUpdate(input: {
|
||||
readonly toolName: string
|
||||
readonly state: RunningToolState
|
||||
readonly output?: string
|
||||
readonly cwd?: string
|
||||
}): ToolCallUpdate {
|
||||
const content = input.output
|
||||
? [
|
||||
@@ -155,9 +160,9 @@ export function runningToolUpdate(input: {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: input.state.title ?? input.toolName,
|
||||
locations: toLocations(input.toolName, input.state.input),
|
||||
rawInput: input.state.input,
|
||||
title: toolTitle(input.toolName, input.state.input, input.state.title),
|
||||
locations: toLocations(input.toolName, input.state.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
|
||||
...(content ? { content } : {}),
|
||||
}
|
||||
}
|
||||
@@ -166,29 +171,32 @@ export function duplicateRunningToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: RunningToolState
|
||||
readonly cwd?: string
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: input.state.title ?? input.toolName,
|
||||
locations: toLocations(input.toolName, input.state.input),
|
||||
rawInput: input.state.input,
|
||||
title: toolTitle(input.toolName, input.state.input, input.state.title),
|
||||
locations: toLocations(input.toolName, input.state.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
|
||||
}
|
||||
}
|
||||
|
||||
export function completedToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: CompletedToolState & { readonly title: string }
|
||||
readonly state: CompletedToolState & { readonly title?: string }
|
||||
readonly cwd?: string
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "completed",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: input.state.title,
|
||||
title: toolTitle(input.toolName, input.state.input, input.state.title),
|
||||
locations: toLocations(input.toolName, input.state.input, input.cwd),
|
||||
content: completedToolContent(input.toolName, input.state),
|
||||
rawInput: input.state.input,
|
||||
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
|
||||
rawOutput: completedToolRawOutput(input.state),
|
||||
}
|
||||
}
|
||||
@@ -197,13 +205,15 @@ export function errorToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: ErrorToolState
|
||||
readonly cwd?: string
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "failed",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: input.toolName,
|
||||
rawInput: input.state.input,
|
||||
title: toolTitle(input.toolName, input.state.input, undefined),
|
||||
locations: toLocations(input.toolName, input.state.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
@@ -253,6 +263,42 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
|
||||
return stringValue((state.metadata as Record<string, unknown>).output)
|
||||
}
|
||||
|
||||
// For shell tools, surface the actual command as the title so it stays visible
|
||||
// before output lands; non-shell tools keep their model-provided title.
|
||||
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
|
||||
if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName
|
||||
return fallback || toolName
|
||||
}
|
||||
|
||||
// Enrich shell rawInput with the resolved working directory so clients can show
|
||||
// where the command runs, unless the model already specified one.
|
||||
function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
|
||||
if (!isShell(toolName)) return input
|
||||
if (input.cwd || input.workdir) return input
|
||||
const workdir = shellWorkdir(input, cwd)
|
||||
return workdir ? { ...input, cwd: workdir } : input
|
||||
}
|
||||
|
||||
function shellWorkdir(input: ToolInput, cwd?: string) {
|
||||
const explicit = stringValue(input.workdir) ?? stringValue(input.cwd)
|
||||
return resolvePath(explicit, cwd) ?? cwd
|
||||
}
|
||||
|
||||
function resolvePath(value: string | undefined, cwd?: string) {
|
||||
if (!value) return undefined
|
||||
if (isAbsolute(value)) return value
|
||||
return resolve(cwd ?? process.cwd(), value)
|
||||
}
|
||||
|
||||
function shellCommand(input: ToolInput) {
|
||||
return stringValue(input.command) ?? stringValue(input.cmd)
|
||||
}
|
||||
|
||||
function isShell(toolName: string) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
return tool === "bash" || tool === "shell"
|
||||
}
|
||||
|
||||
export const mapToolKind = toToolKind
|
||||
export const extractLocations = toLocations
|
||||
export const buildCompletedToolContent = completedToolContent
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Cause } from "effect"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
import { MCP } from "../../mcp"
|
||||
@@ -770,7 +771,7 @@ export const McpDebugCommand = effectCmd({
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: { name: "kilo-debug", version: InstallationVersion }, // kilocode_change
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Identifier } from "@/id/id"
|
||||
import { Instance, type InstanceContext } from "@/kilocode/instance"
|
||||
import { KiloShutdown } from "@/kilocode/cli/shutdown"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { Process } from "@/util/process"
|
||||
import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Process } from "@/util/process"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { Instance, type InstanceContext } from "@/kilocode/instance"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
@@ -1,61 +1 @@
|
||||
import path from "path"
|
||||
|
||||
type Input = {
|
||||
command?: string
|
||||
args?: string[]
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
type Command = {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
const names = new Set(["kilo", "kilocode"])
|
||||
const self = command()
|
||||
|
||||
function clean(input: string[]) {
|
||||
return input.filter((arg, index) => {
|
||||
if (arg === "--cwd") return false
|
||||
if (input[index - 1] === "--cwd") return false
|
||||
if (arg.startsWith("--cwd=")) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function full(input: string, cwd: string) {
|
||||
if (path.isAbsolute(input)) return input
|
||||
return path.resolve(cwd, input)
|
||||
}
|
||||
|
||||
export function command(
|
||||
proc = { argv: process.argv, execArgv: process.execArgv, execPath: process.execPath, cwd: process.cwd() },
|
||||
): Command {
|
||||
const script = proc.argv[1]
|
||||
const bundled = script?.startsWith("/$bunfs/") || (script ? /^[A-Za-z]:[\\/]~BUN[\\/]/.test(script) : false)
|
||||
if (script && !bundled && /\.(ts|js|mjs|cjs)$/.test(script)) {
|
||||
const file = full(script, proc.cwd)
|
||||
const dir = path.dirname(file)
|
||||
const root = path.basename(dir) === "src" ? path.dirname(dir) : proc.cwd
|
||||
return { command: full(proc.execPath, proc.cwd), args: [...clean(proc.execArgv), file], cwd: root }
|
||||
}
|
||||
return { command: full(proc.execPath, proc.cwd), args: [] }
|
||||
}
|
||||
|
||||
export function resolve(input: Input, cmd = self): Input {
|
||||
if (!input.command || !names.has(input.command)) return input
|
||||
const args = input.args ?? []
|
||||
const project = cmd.cwd && args.length === 0 && input.cwd ? [input.cwd] : []
|
||||
return {
|
||||
...input,
|
||||
command: cmd.command,
|
||||
args: [...cmd.args, ...project, ...args],
|
||||
cwd: cmd.cwd ?? input.cwd,
|
||||
}
|
||||
}
|
||||
|
||||
export const KiloPtySelfCommand = {
|
||||
command,
|
||||
resolve,
|
||||
}
|
||||
export { command, KiloPtySelfCommand, resolve } from "@opencode-ai/core/kilocode/pty-self-command"
|
||||
|
||||
@@ -2,13 +2,5 @@
|
||||
// Kilo-specific overrides for the server control plane.
|
||||
// Imported by ../../server/server.ts with minimal kilocode_change markers.
|
||||
|
||||
/** Additional CORS origin check for *.kilo.ai */
|
||||
export function corsOrigin(input: string): string | undefined {
|
||||
if (/^https:\/\/([a-z0-9-]+\.)*kilo\.ai$/.test(input)) {
|
||||
return input
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const DOC_TITLE = "kilo"
|
||||
export const DOC_DESCRIPTION = "kilo api"
|
||||
|
||||
@@ -1,124 +1 @@
|
||||
export function args(command: string) {
|
||||
return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)]
|
||||
}
|
||||
|
||||
const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false);
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false);
|
||||
$OutputEncoding = [Console]::OutputEncoding;
|
||||
`
|
||||
|
||||
function script(command: string) {
|
||||
const pos = prologue(command)
|
||||
const head = command.slice(0, pos)
|
||||
const body = command.slice(pos)
|
||||
const gap = head && !/[;\r\n]\s*$/.test(head) ? "\n" : ""
|
||||
return `${head}${gap}${setup}${body}`
|
||||
}
|
||||
|
||||
function prologue(command: string) {
|
||||
const pos = scan(command, 0)
|
||||
const attr = attrs(command, pos)
|
||||
const body = command.slice(attr)
|
||||
const match = /^param\s*\(/i.exec(body)
|
||||
if (!match) return pos
|
||||
|
||||
const start = attr + match[0].lastIndexOf("(")
|
||||
const end = block(command, start, "(", ")")
|
||||
if (end === undefined) return pos
|
||||
return end
|
||||
}
|
||||
|
||||
function attrs(command: string, start: number) {
|
||||
let pos = start
|
||||
while (pos < command.length) {
|
||||
const next = scan(command, pos)
|
||||
if (command[next] !== "[") return next
|
||||
const end = block(command, next, "[", "]")
|
||||
if (end === undefined) return start
|
||||
pos = end
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
function scan(command: string, start: number) {
|
||||
let pos = start
|
||||
while (pos < command.length) {
|
||||
const next = trivia(command, pos)
|
||||
if (next !== pos) {
|
||||
pos = next
|
||||
continue
|
||||
}
|
||||
const end = line(command, pos)
|
||||
const value = command.slice(pos, end)
|
||||
if (/^using\s+(?:assembly|module|namespace|type)\b/i.test(value)) {
|
||||
pos = end
|
||||
continue
|
||||
}
|
||||
return pos
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
function trivia(command: string, start: number) {
|
||||
let pos = start
|
||||
while (pos < command.length) {
|
||||
while (/\s/.test(command[pos] ?? "")) pos++
|
||||
if (command[pos] === "#") {
|
||||
pos = line(command, pos)
|
||||
continue
|
||||
}
|
||||
if (command.startsWith("<#", pos)) {
|
||||
const end = command.indexOf("#>", pos + 2)
|
||||
if (end === -1) return command.length
|
||||
pos = end + 2
|
||||
continue
|
||||
}
|
||||
return pos
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
function line(command: string, start: number) {
|
||||
const index = command.indexOf("\n", start)
|
||||
if (index === -1) return command.length
|
||||
return index + 1
|
||||
}
|
||||
|
||||
function block(command: string, start: number, open: string, close: string) {
|
||||
let depth = 0
|
||||
let quote: string | undefined
|
||||
for (let pos = start; pos < command.length; pos++) {
|
||||
const char = command[pos]
|
||||
if (quote) {
|
||||
if (quote === "'" && char === "'" && command[pos + 1] === "'") {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if (quote === '"' && char === "`") {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if (char === quote) quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
if (command.startsWith("<#", pos)) {
|
||||
const end = command.indexOf("#>", pos + 2)
|
||||
if (end === -1) return
|
||||
pos = end + 1
|
||||
continue
|
||||
}
|
||||
if (char === "#") {
|
||||
pos = line(command, pos) - 1
|
||||
continue
|
||||
}
|
||||
if (char === open) depth++
|
||||
if (char === close) {
|
||||
depth--
|
||||
if (depth === 0) return pos + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
export { args, PowerShell } from "@opencode-ai/core/kilocode/powershell"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { InteractiveTerminal } from "@/kilocode/interactive-terminal"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { ShellPermission } from "@/tool/shell"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import type { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
|
||||
@@ -61,8 +61,17 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe
|
||||
resetTimeoutOnProgress: true,
|
||||
signal: options.abortSignal,
|
||||
timeout,
|
||||
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
|
||||
onprogress: () => {},
|
||||
},
|
||||
)
|
||||
if (result.isError)
|
||||
throw new Error(
|
||||
result.content
|
||||
.flatMap((item) => (item.type === "text" ? [item.text] : []))
|
||||
.filter((text) => text.trim())
|
||||
.join("\n\n") || "MCP tool returned an error",
|
||||
)
|
||||
if (result.structuredContent === undefined || result.structuredContent === null) return result
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -8,16 +8,18 @@ if (process.platform === "win32" && !("type" in process)) {
|
||||
// kilocode_change end
|
||||
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { type Tool } from "ai"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import {
|
||||
ListRootsRequestSchema,
|
||||
type LoggingMessageNotification,
|
||||
LoggingMessageNotificationSchema,
|
||||
type Tool as MCPToolDef,
|
||||
@@ -45,6 +47,18 @@ import * as SandboxNetwork from "@/kilocode/sandbox/network" // kilocode_change
|
||||
import { McpCatalog } from "./catalog"
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
const CLIENT_OPTIONS = {
|
||||
capabilities: {
|
||||
// https://github.com/anomalyco/opencode/issues/11948
|
||||
// sampling: {},
|
||||
// https://github.com/anomalyco/opencode/issues/23066
|
||||
// elicitation: {},
|
||||
// https://github.com/anomalyco/opencode/issues/2308
|
||||
roots: {},
|
||||
// https://github.com/anomalyco/opencode/issues/28567
|
||||
// tasks: {},
|
||||
},
|
||||
} satisfies ClientOptions
|
||||
|
||||
// kilocode_change start - inject --rm for Docker containers to prevent stopped container accumulation
|
||||
export function ensureDockerRm(cmd: string, args: string[]): string[] {
|
||||
@@ -94,6 +108,14 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP
|
||||
|
||||
type MCPClient = Client
|
||||
|
||||
function createClient(directory: string) {
|
||||
const client = new Client({ name: "kilo", version: InstallationVersion }, CLIENT_OPTIONS) // kilocode_change
|
||||
client.setRequestHandler(ListRootsRequestSchema, () =>
|
||||
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
|
||||
)
|
||||
return client
|
||||
}
|
||||
|
||||
const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
|
||||
identifier: "MCPStatusConnected",
|
||||
})
|
||||
@@ -204,19 +226,21 @@ export const layer = Layer.effect(
|
||||
* Connect a client via the given transport with resource safety:
|
||||
* on failure the transport is closed; on success the caller owns it.
|
||||
*/
|
||||
const connectTransport = (transport: Transport, timeout: number) =>
|
||||
Effect.acquireUseRelease(
|
||||
const connectTransport = Effect.fn("MCP.connectTransport")(function* (transport: Transport, timeout: number) {
|
||||
const directory = yield* InstanceState.directory
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.succeed(transport),
|
||||
(t) =>
|
||||
Effect.tryPromise({
|
||||
try: () => {
|
||||
const client = new Client({ name: "kilo", version: InstallationVersion }) // kilocode_change
|
||||
const client = createClient(directory)
|
||||
return withTimeout(client.connect(t), timeout).then(() => client)
|
||||
},
|
||||
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
||||
}),
|
||||
(t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void),
|
||||
)
|
||||
})
|
||||
|
||||
const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } }
|
||||
|
||||
@@ -806,10 +830,11 @@ export const layer = Layer.effect(
|
||||
authProvider,
|
||||
requestInit: mcpConfig.headers ? { headers: mcpConfig.headers } : undefined,
|
||||
})
|
||||
const directory = yield* InstanceState.directory
|
||||
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => {
|
||||
const client = new Client({ name: "kilo", version: InstallationVersion }) // kilocode_change
|
||||
const client = createClient(directory)
|
||||
return client
|
||||
.connect(transport)
|
||||
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createConnection } from "net"
|
||||
import { createServer } from "http"
|
||||
import { escapeHtml } from "@/util/html"
|
||||
import * as Log from "@opencode-ai/core/util/log" // kilocode_change
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
|
||||
import * as KiloOAuthCallback from "../kilocode/mcp-oauth-callback" // kilocode_change
|
||||
@@ -52,7 +53,7 @@ const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
||||
<div class="container">
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>An error occurred during authorization.</p>
|
||||
<div class="error">${error}</div>
|
||||
<div class="error">${escapeHtml(error)}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -80,6 +81,13 @@ function cleanupStateIndex(oauthState: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function stopIfIdle() {
|
||||
if (pendingAuths.size > 0 || !server) return
|
||||
|
||||
server.close()
|
||||
server = undefined
|
||||
}
|
||||
|
||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
|
||||
|
||||
@@ -97,7 +105,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
@@ -111,13 +119,14 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
cleanupStateIndex(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
stopIfIdle()
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(HTML_ERROR("No authorization code provided"))
|
||||
return
|
||||
}
|
||||
@@ -125,7 +134,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
@@ -137,8 +146,9 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
cleanupStateIndex(state)
|
||||
pending.resolve(code)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(HTML_SUCCESS)
|
||||
stopIfIdle()
|
||||
}
|
||||
|
||||
export async function ensureRunning(redirectUri?: string): Promise<void> {
|
||||
@@ -212,6 +222,7 @@ export function waitForCallback(oauthState: string, mcpName?: string): Promise<s
|
||||
pendingAuths.delete(oauthState)
|
||||
if (mcpName) mcpNameToState.delete(mcpName)
|
||||
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||
stopIfIdle()
|
||||
}
|
||||
}, CALLBACK_TIMEOUT_MS)
|
||||
|
||||
@@ -229,6 +240,7 @@ export function cancelPending(mcpName: string): void {
|
||||
pendingAuths.delete(key)
|
||||
mcpNameToState.delete(mcpName)
|
||||
pending.reject(new Error("Authorization cancelled"))
|
||||
stopIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
return CopilotModels.get(
|
||||
base(auth.enterpriseUrl),
|
||||
{
|
||||
...(provider.options?.headers as Record<string, string> | undefined),
|
||||
Authorization: `Bearer ${auth.refresh}`,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
"X-GitHub-Api-Version": API_VERSION,
|
||||
|
||||
@@ -148,11 +148,12 @@ export const layer = Layer.effect(
|
||||
|
||||
const { Server } = yield* Effect.promise(() => import("../server/server"))
|
||||
|
||||
const serverUrl = Server.url
|
||||
const client = createKiloClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
baseUrl: serverUrl?.toString() ?? "http://localhost:4096",
|
||||
directory: ctx.directory,
|
||||
headers: ServerAuth.headers(),
|
||||
fetch: async (...args) => Server.Default().app.fetch(...args),
|
||||
...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }),
|
||||
})
|
||||
const cfg = yield* config.get()
|
||||
const input: PluginInput = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Hooks, PluginInput } from "@kilocode/plugin"
|
||||
import * as Log from "@opencode-ai/core/util/log" // kilocode_change
|
||||
import { escapeHtml } from "@/util/html"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OAUTH_DUMMY_KEY } from "../../auth"
|
||||
import os from "os"
|
||||
@@ -203,7 +204,7 @@ const HTML_SUCCESS = `<!doctype html>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const HTML_ERROR = (error: string) => `<!doctype html>
|
||||
export const renderOAuthError = (error: string) => `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- kilocode_change start -->
|
||||
@@ -248,7 +249,7 @@ const HTML_ERROR = (error: string) => `<!doctype html>
|
||||
<div class="container">
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>An error occurred during authorization.</p>
|
||||
<div class="error">${error}</div>
|
||||
<div class="error">${escapeHtml(error)}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -281,8 +282,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(renderOAuthError(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -290,8 +291,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(renderOAuthError(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -299,8 +300,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(renderOAuthError(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -311,7 +312,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(HTML_SUCCESS)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export * as PluginPtyEnvironment from "./pty-environment"
|
||||
|
||||
import { PtyEnvironment } from "@opencode-ai/server/pty-environment"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Plugin } from "."
|
||||
|
||||
export const layer = Layer.effect(
|
||||
PtyEnvironment.Service,
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const instances = yield* InstanceStore.Service
|
||||
return PtyEnvironment.Service.of({
|
||||
get: Effect.fn("PtyEnvironment.get")(function* (input) {
|
||||
return yield* instances.provide(
|
||||
{ directory: input.directory },
|
||||
plugin
|
||||
.trigger("shell.env", { cwd: input.cwd }, { env: {} as Record<string, string> })
|
||||
.pipe(Effect.map((result) => result.env)),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -2,6 +2,7 @@ import type { Hooks, PluginInput } from "@kilocode/plugin"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { createServer } from "http"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { escapeHtml } from "@/util/html"
|
||||
|
||||
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
|
||||
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
|
||||
@@ -74,25 +75,6 @@ function generateState(): string {
|
||||
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => {
|
||||
switch (char) {
|
||||
case "&":
|
||||
return "&"
|
||||
case "<":
|
||||
return "<"
|
||||
case ">":
|
||||
return ">"
|
||||
case '"':
|
||||
return """
|
||||
case "'":
|
||||
return "'"
|
||||
default:
|
||||
return char
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
|
||||
@@ -844,7 +844,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
apiKey: apiToken,
|
||||
...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}),
|
||||
})
|
||||
const unified = createUnified()
|
||||
const unified = createUnified({ apiKey: apiToken })
|
||||
|
||||
return {
|
||||
autoload: true,
|
||||
|
||||
@@ -226,7 +226,7 @@ function normalizeMessages(
|
||||
if (
|
||||
model.providerID === "mistral" ||
|
||||
model.api.id.toLowerCase().includes("mistral") ||
|
||||
model.api.id.toLocaleLowerCase().includes("devstral")
|
||||
model.api.id.toLowerCase().includes("devstral")
|
||||
) {
|
||||
const scrub = (id: string) => {
|
||||
return id
|
||||
@@ -730,7 +730,6 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
max: { effort: "max" },
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
id.includes("deepseek-chat") ||
|
||||
id.includes("deepseek-reasoner") ||
|
||||
@@ -1425,6 +1424,96 @@ export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_T
|
||||
return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
// Mirrors Codex's Rust JSON schema compatibility lowering for OpenAI tool schemas.
|
||||
function sanitizeOpenAISchema(value: unknown): unknown {
|
||||
const types = ["string", "number", "boolean", "integer", "object", "array", "null"]
|
||||
const compositionKeys = ["anyOf", "oneOf", "allOf"]
|
||||
|
||||
// JSON Schema's boolean form (`true`/`false`) is unsupported by OpenAI tool schemas.
|
||||
if (typeof value === "boolean") return { type: "string" }
|
||||
if (Array.isArray(value)) return value.map(sanitizeOpenAISchema)
|
||||
if (!isPlainObject(value)) return value
|
||||
|
||||
const result: JsonRecord = {}
|
||||
|
||||
if (typeof value.$ref === "string") result.$ref = value.$ref
|
||||
if (typeof value.description === "string") result.description = value.description
|
||||
if ("const" in value) result.enum = [value.const]
|
||||
else if (Array.isArray(value.enum)) result.enum = value.enum
|
||||
|
||||
if (isPlainObject(value.properties)) {
|
||||
result.properties = Object.fromEntries(
|
||||
Object.entries(value.properties).map(([key, item]) => [key, sanitizeOpenAISchema(item)]),
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value.required)) {
|
||||
result.required = value.required.filter((item) => typeof item === "string")
|
||||
}
|
||||
|
||||
if ("items" in value) result.items = sanitizeOpenAISchema(value.items)
|
||||
|
||||
if ("additionalProperties" in value) {
|
||||
result.additionalProperties =
|
||||
typeof value.additionalProperties === "boolean"
|
||||
? value.additionalProperties
|
||||
: sanitizeOpenAISchema(value.additionalProperties)
|
||||
}
|
||||
|
||||
for (const key of compositionKeys) {
|
||||
if (Array.isArray(value[key])) result[key] = value[key].map(sanitizeOpenAISchema)
|
||||
}
|
||||
|
||||
for (const key of ["$defs", "definitions"]) {
|
||||
if (isPlainObject(value[key])) {
|
||||
result[key] = Object.fromEntries(
|
||||
Object.entries(value[key]).map(([name, item]) => [name, sanitizeOpenAISchema(item)]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const schemaTypes =
|
||||
typeof value.type === "string"
|
||||
? types.includes(value.type)
|
||||
? [value.type]
|
||||
: []
|
||||
: Array.isArray(value.type)
|
||||
? value.type.filter((item) => typeof item === "string" && types.includes(item))
|
||||
: []
|
||||
|
||||
if (schemaTypes.length === 0 && (typeof result.$ref === "string" || compositionKeys.some((key) => key in result))) {
|
||||
return result
|
||||
}
|
||||
|
||||
// MCP schemas may omit `type` while still using keywords that imply one.
|
||||
// Keep the schema usable after unsupported keywords are dropped.
|
||||
const inferredTypes =
|
||||
schemaTypes.length > 0
|
||||
? schemaTypes
|
||||
: ["properties", "required", "additionalProperties"].some((key) => key in value)
|
||||
? ["object"]
|
||||
: ["items", "prefixItems"].some((key) => key in value)
|
||||
? ["array"]
|
||||
: "enum" in result || "format" in value
|
||||
? ["string"]
|
||||
: ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].some((key) => key in value)
|
||||
? ["number"]
|
||||
: []
|
||||
|
||||
if (inferredTypes.length === 0) return {}
|
||||
|
||||
result.type = inferredTypes.length === 1 ? inferredTypes[0] : inferredTypes
|
||||
if (inferredTypes.includes("object") && !("properties" in result)) result.properties = {}
|
||||
if (inferredTypes.includes("array") && !("items" in result)) result.items = { type: "string" }
|
||||
return result
|
||||
}
|
||||
|
||||
export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
|
||||
/*
|
||||
if (["openai", "azure"].includes(providerID)) {
|
||||
@@ -1444,6 +1533,11 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7
|
||||
}
|
||||
*/
|
||||
|
||||
if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") {
|
||||
schema = sanitizeOpenAISchema(schema) as JSONSchema7
|
||||
// Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget.
|
||||
}
|
||||
|
||||
if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) {
|
||||
const sanitizeMoonshot = (obj: unknown): unknown => {
|
||||
if (obj === null || typeof obj !== "object") return obj
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
export * as PtyPreparation from "./pty-preparation"
|
||||
|
||||
import { Config } from "@/config/config"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { KiloPtySelfCommand } from "@/kilocode/pty/self-command" // kilocode_change - ported from the deleted @/pty module
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const prepareCreate = Effect.fn("PtyPreparation.prepareCreate")(function* (input: Pty.CreateInput) {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
// kilocode_change start - resolve Kilo self-commands (e.g. bare `kilo`) to the real binary + args + project cwd
|
||||
const resolved = KiloPtySelfCommand.resolve({
|
||||
command: input.command,
|
||||
args: input.args ? [...input.args] : undefined,
|
||||
cwd: input.cwd,
|
||||
})
|
||||
const command = resolved.command || Shell.preferred((yield* config.get()).shell)
|
||||
const baseArgs = resolved.args ?? []
|
||||
const cwd = resolved.cwd || (yield* InstanceState.context).directory
|
||||
// kilocode_change end
|
||||
const args = Shell.login(command) ? [...baseArgs, "-l"] : [...baseArgs]
|
||||
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} })
|
||||
const env = {
|
||||
...process.env,
|
||||
...input.env,
|
||||
...shell.env,
|
||||
TERM: "xterm-256color",
|
||||
KILO_TERMINAL: "1",
|
||||
} as Record<string, string>
|
||||
// kilocode_change start - ported from the deleted @/pty module.
|
||||
// Don't leak the kilo server's auth credential into user shells: anything the shell forks (npm
|
||||
// post-install, `curl | bash`, compromised tools) would otherwise see the password for free. Users
|
||||
// who need `kilo run`/`kilo tui attach` to auto-connect from a kilo-spawned terminal pass --password.
|
||||
delete env.KILO_SERVER_PASSWORD
|
||||
delete env.KILO_SERVER_USERNAME
|
||||
// kilocode_change end
|
||||
if (process.platform === "win32") {
|
||||
env.LC_ALL = "C.UTF-8"
|
||||
env.LC_CTYPE = "C.UTF-8"
|
||||
env.LANG = "C.UTF-8"
|
||||
}
|
||||
return { command, args, cwd, title: input.title, env }
|
||||
})
|
||||
@@ -27,6 +27,10 @@ const ConsoleStateResponse = Schema.Struct({
|
||||
switchableOrgCount: NonNegativeInt,
|
||||
}).annotate({ identifier: "ConsoleState" })
|
||||
|
||||
const CapabilitiesResponse = Schema.Struct({
|
||||
backgroundSubagents: Schema.Boolean,
|
||||
}).annotate({ identifier: "ExperimentalCapabilities" })
|
||||
|
||||
const ConsoleOrgOption = Schema.Struct({
|
||||
accountID: Schema.String,
|
||||
accountEmail: Schema.String,
|
||||
@@ -106,6 +110,7 @@ export const WorktreeDiffFileQuery = Schema.Struct({
|
||||
// kilocode_change end
|
||||
|
||||
export const ExperimentalPaths = {
|
||||
capabilities: "/experimental/capabilities",
|
||||
console: "/experimental/console",
|
||||
consoleOrgs: "/experimental/console/orgs",
|
||||
consoleSwitch: "/experimental/console/switch",
|
||||
@@ -125,6 +130,16 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
.add(
|
||||
HttpApiGroup.make("experimental")
|
||||
.add(
|
||||
HttpApiEndpoint.get("capabilities", ExperimentalPaths.capabilities, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(CapabilitiesResponse, "Experimental capabilities"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.capabilities.get",
|
||||
summary: "Get experimental capabilities",
|
||||
description: "Get experimental features enabled on the OpenCode server.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(ConsoleStateResponse, "Active Console provider metadata"),
|
||||
|
||||
@@ -53,6 +53,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
||||
const background = yield* BackgroundJob.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () {
|
||||
return { backgroundSubagents: flags.experimentalBackgroundSubagents }
|
||||
})
|
||||
|
||||
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
|
||||
const [state, groups] = yield* Effect.all(
|
||||
[
|
||||
@@ -270,25 +274,24 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
||||
return yield* mcp.resources()
|
||||
})
|
||||
|
||||
return (
|
||||
handlers
|
||||
.handle("console", getConsole)
|
||||
.handle("consoleOrgs", listConsoleOrgs)
|
||||
.handle("consoleSwitch", switchConsole)
|
||||
.handle("tool", tool)
|
||||
.handle("toolIDs", toolIDs)
|
||||
.handle("worktree", worktree)
|
||||
.handle("worktreeCreate", worktreeCreate)
|
||||
.handle("worktreeRemove", worktreeRemove)
|
||||
.handle("worktreeReset", worktreeReset)
|
||||
// kilocode_change start
|
||||
.handle("worktreeDiff", worktreeDiff)
|
||||
.handle("worktreeDiffSummary", worktreeDiffSummary)
|
||||
.handle("worktreeDiffFile", worktreeDiffFile)
|
||||
// kilocode_change end
|
||||
.handle("session", session)
|
||||
.handle("sessionBackground", sessionBackground)
|
||||
.handle("resource", resource)
|
||||
)
|
||||
return handlers
|
||||
.handle("capabilities", capabilities)
|
||||
.handle("console", getConsole)
|
||||
.handle("consoleOrgs", listConsoleOrgs)
|
||||
.handle("consoleSwitch", switchConsole)
|
||||
.handle("tool", tool)
|
||||
.handle("toolIDs", toolIDs)
|
||||
.handle("worktree", worktree)
|
||||
.handle("worktreeCreate", worktreeCreate)
|
||||
.handle("worktreeRemove", worktreeRemove)
|
||||
.handle("worktreeReset", worktreeReset)
|
||||
// kilocode_change start
|
||||
.handle("worktreeDiff", worktreeDiff)
|
||||
.handle("worktreeDiffSummary", worktreeDiffSummary)
|
||||
.handle("worktreeDiffFile", worktreeDiffFile)
|
||||
// kilocode_change end
|
||||
.handle("session", session)
|
||||
.handle("sessionBackground", sessionBackground)
|
||||
.handle("resource", resource)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { registerDisposer } from "@/effect/instance-registry"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { PtyPreparation } from "@/pty-preparation"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { handlePtyInput } from "@opencode-ai/core/pty/input"
|
||||
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
|
||||
import { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@opencode-ai/server/cors"
|
||||
import {
|
||||
PTY_CONNECT_TICKET_QUERY,
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} from "@/server/shared/pty-ticket"
|
||||
import { Effect, Layer, Option, Schema } from "effect"
|
||||
import { Effect, Layer, Option, Queue, Schema } from "effect"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
@@ -36,10 +35,14 @@ const ticketScope = Effect.gen(function* () {
|
||||
return { directory: instance?.directory, workspaceID }
|
||||
})
|
||||
|
||||
// Legacy surface compatibility: before exited-session retention, sessions vanished the moment
|
||||
// their process exited. These routes preserve that observable behavior — exited sessions are
|
||||
// invisible here — while the canonical /api/pty surface exposes them until removal.
|
||||
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const cors = yield* CorsConfig
|
||||
const plugin = yield* Plugin.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const unregister = registerDisposer((directory) =>
|
||||
Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
||||
@@ -59,33 +62,42 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
})
|
||||
|
||||
const list = Effect.fn("PtyHttpApi.list")(function* () {
|
||||
return yield* pty(Pty.Service.use((service) => service.list()))
|
||||
const sessions = yield* pty(Pty.Service.use((service) => service.list()))
|
||||
return sessions.filter((info) => info.status === "running")
|
||||
})
|
||||
|
||||
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
|
||||
const cwd = ctx.payload.cwd || (yield* InstanceState.context).directory
|
||||
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} as Record<string, string> })
|
||||
return yield* pty(
|
||||
Pty.Service.use((service) =>
|
||||
Effect.flatMap(
|
||||
PtyPreparation.prepareCreate({
|
||||
...ctx.payload,
|
||||
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
||||
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
|
||||
}),
|
||||
service.create,
|
||||
),
|
||||
service.create({
|
||||
...ctx.payload,
|
||||
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
||||
cwd,
|
||||
env: { ...ctx.payload.env, ...shell.env },
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
return yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
Effect.catchTag(
|
||||
"Pty.NotFoundError",
|
||||
(error) =>
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flatMap((info) =>
|
||||
info.status === "running"
|
||||
? Effect.succeed(info)
|
||||
: new ApiError.PtyNotFoundError({
|
||||
ptyID: ctx.params.ptyID,
|
||||
message: `PTY session not found: ${ctx.params.ptyID}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -94,6 +106,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
params: { ptyID: PtyID }
|
||||
payload: typeof Pty.UpdateInput.Type
|
||||
}) {
|
||||
yield* get(ctx)
|
||||
return yield* pty(
|
||||
Pty.Service.use((service) =>
|
||||
service.update(ctx.params.ptyID, {
|
||||
@@ -102,26 +115,27 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
}),
|
||||
),
|
||||
).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
Effect.catchTag(
|
||||
"Pty.NotFoundError",
|
||||
(error) =>
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
yield* get(ctx)
|
||||
yield* pty(Pty.Service.use((service) => service.remove(ctx.params.ptyID))).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
Effect.catchTag(
|
||||
"Pty.NotFoundError",
|
||||
(error) =>
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
@@ -131,16 +145,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 ApiError.PtyForbiddenError({ message: "Invalid PTY connect token request" })
|
||||
yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* get(ctx)
|
||||
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* ticketScope) })
|
||||
})
|
||||
|
||||
@@ -180,7 +185,7 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
|
||||
request: HttpServerRequest.HttpServerRequest
|
||||
}) {
|
||||
const exists = yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
|
||||
Effect.as(true),
|
||||
Effect.map((info) => info.status === "running"),
|
||||
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
|
||||
)
|
||||
if (!exists) return HttpServerResponse.empty({ status: 404 })
|
||||
@@ -214,48 +219,53 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
|
||||
yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT())
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const writeScoped = (effect: Effect.Effect<void, unknown>) => {
|
||||
bridge.fork(effect.pipe(Effect.catch(() => Effect.void)))
|
||||
}
|
||||
let closed = false
|
||||
const adapter = {
|
||||
get readyState() {
|
||||
return closed ? 3 : 1
|
||||
},
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => {
|
||||
if (closed) return
|
||||
writeScoped(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data))
|
||||
},
|
||||
close: (code?: number, reason?: string) => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
writeScoped(write(new Socket.CloseEvent(code, reason)))
|
||||
},
|
||||
}
|
||||
const handler = yield* pty(
|
||||
Pty.Service.use((service) => service.connect(ctx.params.ptyID, adapter, cursor)),
|
||||
).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
|
||||
// The handshake runs inside `socket.runRaw`, after the input callback is
|
||||
// registered, so the client cannot send frames before PTY input is wired.
|
||||
yield* socket
|
||||
.runRaw((message) => handlePtyInput(handler, message))
|
||||
.pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
closed = true
|
||||
handler.onClose()
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
// Outbound frames flow through one queue drained by a single writer so replay, live
|
||||
// output, and the close frame keep their order.
|
||||
const outbox = yield* Queue.unbounded<string | Uint8Array | Socket.CloseEvent>()
|
||||
const attachment = yield* pty(
|
||||
Pty.Service.use((service) =>
|
||||
service.attach(ctx.params.ptyID, {
|
||||
cursor,
|
||||
onData: (chunk) => Queue.offerUnsafe(outbox, chunk),
|
||||
onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)),
|
||||
}),
|
||||
),
|
||||
).pipe(
|
||||
Effect.catchTags({
|
||||
"Pty.NotFoundError": () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
"Pty.ExitedError": () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
}),
|
||||
)
|
||||
if (!attachment) return HttpServerResponse.empty()
|
||||
|
||||
for (const chunk of PtyProtocol.chunks(attachment.replay)) Queue.offerUnsafe(outbox, chunk)
|
||||
Queue.offerUnsafe(outbox, PtyProtocol.metaFrame(attachment.cursor))
|
||||
attachment.activate()
|
||||
|
||||
const drain = Effect.gen(function* () {
|
||||
while (true) {
|
||||
const item = yield* Queue.take(outbox)
|
||||
yield* write(item)
|
||||
if (item instanceof Socket.CloseEvent) return
|
||||
}
|
||||
})
|
||||
|
||||
// The reader runs concurrently with the writer; whichever finishes first ends the
|
||||
// connection and the attachment is always released.
|
||||
yield* Effect.race(
|
||||
drain,
|
||||
socket.runRaw((message) => {
|
||||
const decoded = PtyProtocol.decodeInput(message)
|
||||
if (decoded !== undefined) attachment.write(decoded)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.ensuring(Effect.sync(() => attachment.detach())),
|
||||
Effect.orDie,
|
||||
)
|
||||
return HttpServerResponse.empty()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ import { MCP } from "@/mcp"
|
||||
import { McpAuth } from "@/mcp/auth"
|
||||
import { Permission } from "@/permission"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { PluginPtyEnvironment } from "@/plugin/pty-environment"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Project } from "@/project/project"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
@@ -70,7 +71,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
||||
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors"
|
||||
import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
@@ -188,6 +189,7 @@ const serverRoutes = HttpApiBuilder.layer(Api).pipe(
|
||||
// kilocode_change start - effective references must be ready before any V2 location consumer runs
|
||||
Layer.provide(handlers.pipe(Layer.provide(locationServiceMapLayer), Layer.provide(referenceReconcilerLayer))),
|
||||
// kilocode_change end
|
||||
Layer.provide(PluginPtyEnvironment.layer),
|
||||
Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
|
||||
)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { HttpApiApp } from "./routes/instance/httpapi/server"
|
||||
import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
|
||||
import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
|
||||
import { PublicApi } from "./routes/instance/httpapi/public"
|
||||
import type { CorsOptions } from "./cors"
|
||||
import type { CorsOptions } from "@opencode-ai/server/cors"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import * as KiloListener from "@/kilocode/server/listener" // kilocode_change
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function openapi() {
|
||||
return OpenApi.fromApi(PublicApi)
|
||||
}
|
||||
|
||||
export let url: URL
|
||||
export let url: URL | undefined
|
||||
|
||||
export async function listen(opts: ListenOptions): Promise<Listener> {
|
||||
const listener = await Effect.runPromise(listenEffect(opts))
|
||||
@@ -94,16 +94,15 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unkno
|
||||
const state = yield* startWithPortFallback(opts)
|
||||
const address = yield* tcpAddress(state)
|
||||
const listenerUrl = makeURL(opts.hostname, address.port)
|
||||
url = listenerUrl
|
||||
|
||||
const unpublishMdns = yield* setupMdns(opts, address.port, state.scope)
|
||||
url = listenerUrl
|
||||
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: address.port,
|
||||
url: listenerUrl,
|
||||
urls: serverUrls(opts.hostname, address.port), // kilocode_change
|
||||
stop: yield* makeStop(state, unpublishMdns),
|
||||
stop: yield* makeStop(state, unpublishMdns, listenerUrl),
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -180,10 +179,19 @@ function setupMdns(opts: ListenOptions, port: number, scope: Scope.Scope) {
|
||||
})
|
||||
}
|
||||
|
||||
function makeStop(state: ListenerState, unpublishMdns: Effect.Effect<void>) {
|
||||
function makeStop(state: ListenerState, unpublishMdns: Effect.Effect<void>, listenerUrl: URL) {
|
||||
return Effect.gen(function* () {
|
||||
const forceCloseOnce = yield* Effect.cached(forceClose(state).pipe(Effect.ignore))
|
||||
const closeScopeOnce = yield* Effect.cached(Scope.close(state.scope, Exit.void).pipe(Effect.ignore))
|
||||
const closeScopeOnce = yield* Effect.cached(
|
||||
Scope.close(state.scope, Exit.void).pipe(
|
||||
Effect.ignore,
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (url === listenerUrl) url = undefined
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return (close?: boolean) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -35,7 +35,7 @@ import { SessionCompaction } from "./compaction"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Instruction } from "./instruction"
|
||||
import { Plugin } from "../plugin"
|
||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||
import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { MCP } from "../mcp"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
@@ -54,7 +54,7 @@ import { Tool } from "@/tool/tool"
|
||||
import { Permission } from "@/permission"
|
||||
import { SessionStatus } from "./status"
|
||||
import { LLM } from "./llm"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
@@ -1698,28 +1698,6 @@ export const layer = Layer.effect(
|
||||
if (step === 1)
|
||||
yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
|
||||
if (step > 1 && lastFinished) {
|
||||
for (const m of msgs) {
|
||||
// kilocode_change start - compare chronology, not generated IDs
|
||||
const finishedBeforeMessage =
|
||||
latest.finishedMessage && KiloSessionMessageOrder.compare(latest.finishedMessage, m) < 0
|
||||
if (m.info.role !== "user" || !finishedBeforeMessage) continue
|
||||
// kilocode_change end
|
||||
for (const p of m.parts) {
|
||||
if (p.type !== "text" || p.ignored || p.synthetic) continue
|
||||
if (!p.text.trim()) continue
|
||||
p.text = [
|
||||
"<system-reminder>",
|
||||
"The user sent the following message:",
|
||||
p.text,
|
||||
"",
|
||||
"Please address this message and continue with your tasks.",
|
||||
"</system-reminder>",
|
||||
].join("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
|
||||
// kilocode_change start — ephemeral context injection + post-summary
|
||||
@@ -1770,7 +1748,7 @@ export const layer = Layer.effect(
|
||||
sessionID,
|
||||
parentSessionID: session.parentID,
|
||||
system,
|
||||
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],
|
||||
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : [])],
|
||||
tools,
|
||||
model,
|
||||
toolChoice: format.type === "json_schema" ? "required" : undefined,
|
||||
|
||||
@@ -142,7 +142,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
if (!execute) continue
|
||||
|
||||
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
|
||||
const transformed = ProviderTransform.schema(input.model, schema)
|
||||
const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} })
|
||||
item.inputSchema = jsonSchema(transformed)
|
||||
item.execute = (args, opts) =>
|
||||
run.promise(
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Config } from "@/config/config"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { ShellID } from "./shell/id"
|
||||
|
||||
import * as Truncate from "./truncate"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -517,7 +517,7 @@ describe("acp event routing", () => {
|
||||
expect(harness.updates).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("emits synthetic pending before the first running tool update", async () => {
|
||||
it("exposes the shell command on the synthetic pending tool call", async () => {
|
||||
const harness = createHarness()
|
||||
await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" }))
|
||||
|
||||
@@ -527,7 +527,14 @@ describe("acp event routing", () => {
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
])
|
||||
expect(harness.updates[0]?.update).toMatchObject({ status: "pending", toolCallId: "call_1" })
|
||||
expect(harness.updates[0]?.update).toMatchObject({
|
||||
status: "pending",
|
||||
toolCallId: "call_1",
|
||||
title: "printf hello",
|
||||
kind: "execute",
|
||||
locations: [{ path: "/workspace" }],
|
||||
rawInput: { cmd: "printf hello", cwd: "/workspace" },
|
||||
})
|
||||
expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolve } from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
completedToolContent,
|
||||
@@ -37,7 +38,13 @@ describe("acp tool conversion", () => {
|
||||
expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
|
||||
{ path: "/tmp/outside" },
|
||||
])
|
||||
expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
|
||||
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
|
||||
// Relative workdir resolves against cwd via the platform path resolver (backslashes on Windows).
|
||||
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
|
||||
{ path: resolve("/workspace", "subdir") },
|
||||
])
|
||||
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
|
||||
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
|
||||
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@/bus"
|
||||
import { BackgroundProcess } from "@/kilocode/background-process"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Plugin } from "@/plugin"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Config } from "@/config/config"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { InteractiveTerminalTool } from "@/kilocode/tool/interactive-terminal"
|
||||
import { Plugin } from "@/plugin"
|
||||
import type { Permission } from "@/permission"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import type { Tool } from "@/tool/tool"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
|
||||
@@ -20,7 +20,7 @@ import { SandboxStore } from "@/kilocode/sandbox/store"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../../fixture/fixture"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import * as PowerShell from "@/kilocode/shell/shell"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
|
||||
const command = `Write-Output "こんにちは 😀"; Write-Output '$value'; Write-Output \`tick\`
|
||||
Write-Output "done"`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { expect, mock, beforeEach } from "bun:test"
|
||||
import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -40,6 +41,8 @@ interface MockClientState {
|
||||
{ resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string }
|
||||
>
|
||||
closed: boolean
|
||||
clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
|
||||
requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
|
||||
notificationHandlers: Map<unknown, (...args: any[]) => any>
|
||||
}
|
||||
|
||||
@@ -77,6 +80,7 @@ function getOrCreateClientState(name?: string): MockClientState {
|
||||
promptPages: {},
|
||||
resourcePages: {},
|
||||
closed: false,
|
||||
requestHandlers: new Map(),
|
||||
notificationHandlers: new Map(),
|
||||
}
|
||||
clientStates.set(key, state)
|
||||
@@ -151,8 +155,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
_state!: MockClientState
|
||||
transport: any
|
||||
|
||||
constructor(_opts: any) {
|
||||
constructor(_info: any, options?: MockClientState["clientOptions"]) {
|
||||
clientCreateCount++
|
||||
this._state = getOrCreateClientState(lastCreatedClientName)
|
||||
this._state.clientOptions = options
|
||||
}
|
||||
|
||||
async connect(transport: { start: () => Promise<void> }) {
|
||||
@@ -162,6 +168,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
this._state = getOrCreateClientState(lastCreatedClientName)
|
||||
}
|
||||
|
||||
setRequestHandler(schema: unknown, handler: (...args: any[]) => Promise<any>) {
|
||||
this._state.requestHandlers.set(schema, handler)
|
||||
}
|
||||
|
||||
setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
|
||||
this._state?.notificationHandlers.set(schema, handler)
|
||||
}
|
||||
@@ -319,6 +329,28 @@ it.instance(
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
it.instance(
|
||||
"advertises and lists the instance directory as its root",
|
||||
() =>
|
||||
MCP.Service.use((mcp: MCPNS.Interface) =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
lastCreatedClientName = "roots"
|
||||
yield* mcp.add("roots", { type: "local", command: ["echo", "test"] })
|
||||
|
||||
const state = getOrCreateClientState("roots")
|
||||
expect(state.clientOptions?.capabilities?.roots).toEqual({})
|
||||
expect(state.clientOptions?.capabilities?.roots?.listChanged).toBeUndefined()
|
||||
|
||||
const handler = state.requestHandlers.get(ListRootsRequestSchema)
|
||||
expect(handler).toBeDefined()
|
||||
const result = yield* Effect.promise(() => handler?.() ?? Promise.reject(new Error("roots handler missing")))
|
||||
expect(result).toEqual({ roots: [{ uri: pathToFileURL(directory).href }] })
|
||||
}),
|
||||
),
|
||||
{ config: { mcp: {} } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"local mcp cwd resolves relative paths against instance directory",
|
||||
() =>
|
||||
|
||||
@@ -87,6 +87,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||
// Mock the MCP SDK Client
|
||||
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
Client: class MockClient {
|
||||
setRequestHandler() {}
|
||||
|
||||
async connect(transport: { start: () => Promise<void> }) {
|
||||
await transport.start()
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||
// Mock the MCP SDK Client to trigger OAuth flow
|
||||
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
Client: class MockClient {
|
||||
setRequestHandler() {}
|
||||
|
||||
async connect(transport: { start: () => Promise<void> }) {
|
||||
await transport.start()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user