mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff736fbbc8 |
@@ -178,7 +178,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
|||||||
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
|
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
|
||||||
reasoningField: Schema.optional(Schema.String),
|
reasoningField: Schema.optional(Schema.String),
|
||||||
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
||||||
requireFinishReason: Schema.optional(Schema.Boolean),
|
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export namespace LanguageModelCompatibility {
|
export namespace LanguageModelCompatibility {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
|
|||||||
const updated = LanguageModel.update(base, {
|
const updated = LanguageModel.update(base, {
|
||||||
route: responsesRoute,
|
route: responsesRoute,
|
||||||
defaults: { generation: { maxTokens: 20 } },
|
defaults: { generation: { maxTokens: 20 } },
|
||||||
compatibility: { toolSchema: "gemini", requireFinishReason: false },
|
compatibility: { toolSchema: "gemini" },
|
||||||
})
|
})
|
||||||
const updatedInput = LanguageModel.input(updated)
|
const updatedInput = LanguageModel.input(updated)
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
|
|||||||
expect(String(updated.id)).toBe("fake-model")
|
expect(String(updated.id)).toBe("fake-model")
|
||||||
expect(updated.route).toBe(responsesRoute)
|
expect(updated.route).toBe(responsesRoute)
|
||||||
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
|
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
|
||||||
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
|
expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
|
||||||
expect(updatedInput.defaults).toBe(updated.defaults)
|
expect(updatedInput.defaults).toBe(updated.defaults)
|
||||||
expect(updatedInput.compatibility).toBe(updated.compatibility)
|
expect(updatedInput.compatibility).toBe(updated.compatibility)
|
||||||
expect(String(updatedInput.provider)).toBe("fake")
|
expect(String(updatedInput.provider)).toBe("fake")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const statusLabels = {
|
|||||||
connected: "mcp.status.connected",
|
connected: "mcp.status.connected",
|
||||||
failed: "mcp.status.failed",
|
failed: "mcp.status.failed",
|
||||||
needs_auth: "mcp.status.needs_auth",
|
needs_auth: "mcp.status.needs_auth",
|
||||||
|
needs_client_registration: "mcp.status.needs_client_registration",
|
||||||
disabled: "mcp.status.disabled",
|
disabled: "mcp.status.disabled",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
}
|
}
|
||||||
const error = () => {
|
const error = () => {
|
||||||
const s = mcpStatus()
|
const s = mcpStatus()
|
||||||
if (s?.status === "failed") return s.error
|
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
||||||
}
|
}
|
||||||
const enabled = () => status() === "connected"
|
const enabled = () => status() === "connected"
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -426,7 +426,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
"bg-icon-success-base": status() === "connected",
|
"bg-icon-success-base": status() === "connected",
|
||||||
"bg-icon-critical-base": status() === "failed",
|
"bg-icon-critical-base": status() === "failed",
|
||||||
"bg-border-weak-base": status() === "disabled",
|
"bg-border-weak-base": status() === "disabled",
|
||||||
"bg-icon-warning-base": status() === "needs_auth",
|
"bg-icon-warning-base":
|
||||||
|
status() === "needs_auth" || status() === "needs_client_registration",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span class="flex flex-col min-w-0 flex-1">
|
<span class="flex flex-col min-w-0 flex-1">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ describe("hasNonBlockingServiceIssue", () => {
|
|||||||
test("detects MCP failures that do not block chatting", () => {
|
test("detects MCP failures that do not block chatting", () => {
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ describe("hasNonBlockingServiceIssue", () => {
|
|||||||
describe("hasServiceNeedingAttention", () => {
|
describe("hasServiceNeedingAttention", () => {
|
||||||
test("detects MCP states that need user attention", () => {
|
test("detects MCP states that need user attention", () => {
|
||||||
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("ignores states that do not need user attention", () => {
|
test("ignores states that do not need user attention", () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
|
|||||||
import type { McpServer } from "@opencode-ai/client/promise"
|
import type { McpServer } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
||||||
return input.mcp.some((status) => status === "needs_auth")
|
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasNonBlockingServiceIssue(input: {
|
export function hasNonBlockingServiceIssue(input: {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export async function toggleMcp(input: {
|
|||||||
needs_auth: input.authenticate,
|
needs_auth: input.authenticate,
|
||||||
disabled: input.connect,
|
disabled: input.connect,
|
||||||
failed: input.connect,
|
failed: input.connect,
|
||||||
|
needs_client_registration: input.connect,
|
||||||
}[input.status]()
|
}[input.status]()
|
||||||
await input.refresh()
|
await input.refresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,32 +140,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
description: "List all available models",
|
description: "List all available models",
|
||||||
params: ServerParams,
|
params: ServerParams,
|
||||||
}),
|
}),
|
||||||
Spec.make("export", {
|
|
||||||
description: "Export session data as JSON",
|
|
||||||
params: {
|
|
||||||
...ServerParams,
|
|
||||||
session: Flag.string("session").pipe(
|
|
||||||
Flag.withAlias("s"),
|
|
||||||
Flag.withDescription("Session ID to export to stdout"),
|
|
||||||
Flag.optional,
|
|
||||||
),
|
|
||||||
sanitize: Flag.boolean("sanitize").pipe(
|
|
||||||
Flag.withDescription("Redact sensitive transcript and file data"),
|
|
||||||
Flag.withDefault(false),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Spec.make("import", {
|
|
||||||
description: "Import session data from a JSON file or URL",
|
|
||||||
params: {
|
|
||||||
...ServerParams,
|
|
||||||
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
|
|
||||||
directory: Flag.string("directory").pipe(
|
|
||||||
Flag.withDescription("Directory in which to import the session"),
|
|
||||||
Flag.optional,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Spec.make("mini", {
|
Spec.make("mini", {
|
||||||
description: "Start the minimal interactive interface",
|
description: "Start the minimal interactive interface",
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
|
|
||||||
import { Service } from "@opencode-ai/client/effect/service"
|
|
||||||
import { Effect, Option } from "effect"
|
|
||||||
import { EOL, tmpdir } from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
import { emitKeypressEvents, type Key } from "node:readline"
|
|
||||||
import { Commands } from "../commands"
|
|
||||||
import { Runtime } from "../../framework/runtime"
|
|
||||||
import { ServerConnection } from "../../services/server-connection"
|
|
||||||
|
|
||||||
export default Runtime.handler(
|
|
||||||
Commands.commands.export,
|
|
||||||
Effect.fn("cli.export")(function* (input) {
|
|
||||||
const server = yield* ServerConnection.resolve({
|
|
||||||
server: Option.getOrUndefined(input.server),
|
|
||||||
standalone: input.standalone,
|
|
||||||
})
|
|
||||||
const client = OpenCode.make({
|
|
||||||
baseUrl: server.endpoint.url,
|
|
||||||
headers: Service.headers(server.endpoint),
|
|
||||||
})
|
|
||||||
const requested = Option.getOrUndefined(input.session)
|
|
||||||
const selected = requested
|
|
||||||
? undefined
|
|
||||||
: yield* Effect.promise(async () => {
|
|
||||||
const location = await client.location.get({ location: { directory: process.cwd() } })
|
|
||||||
const page = await client.session.list({
|
|
||||||
directory: location.directory,
|
|
||||||
workspace: location.workspaceID,
|
|
||||||
parentID: null,
|
|
||||||
order: "desc",
|
|
||||||
limit: 50,
|
|
||||||
})
|
|
||||||
if (page.data.length === 0) {
|
|
||||||
process.stderr.write(`No sessions found${EOL}`)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return selectSession(page.data, input.sanitize)
|
|
||||||
})
|
|
||||||
const sessionID = requested ?? selected?.session.id
|
|
||||||
if (!sessionID) return
|
|
||||||
const data = yield* Effect.promise(() =>
|
|
||||||
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
|
|
||||||
)
|
|
||||||
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
type Selection = { session: SessionInfo; sanitize: boolean }
|
|
||||||
|
|
||||||
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
|
|
||||||
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
|
|
||||||
const input = process.stdin
|
|
||||||
const output = process.stderr
|
|
||||||
const wasRaw = input.isRaw
|
|
||||||
const wasPaused = input.isPaused()
|
|
||||||
const date = new Intl.DateTimeFormat(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
})
|
|
||||||
const columns = output.columns ?? 100
|
|
||||||
const titleWidth = Math.max(8, Math.min(48, columns - 34))
|
|
||||||
let selected = 0
|
|
||||||
let offset = 0
|
|
||||||
let sanitize = initialSanitize
|
|
||||||
let height = 0
|
|
||||||
|
|
||||||
const render = () => {
|
|
||||||
const visible = sessions.slice(offset, offset + 10)
|
|
||||||
const lines = [" \x1b[36mExport session\x1b[0m", ""]
|
|
||||||
lines.push(
|
|
||||||
...visible.map((session) => {
|
|
||||||
const index = sessions.indexOf(session)
|
|
||||||
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
|
|
||||||
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
|
|
||||||
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
|
|
||||||
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
|
|
||||||
}),
|
|
||||||
"",
|
|
||||||
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
|
|
||||||
"",
|
|
||||||
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
|
|
||||||
)
|
|
||||||
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
|
||||||
output.write(lines.join(EOL) + EOL)
|
|
||||||
height = lines.length
|
|
||||||
}
|
|
||||||
const clear = () => {
|
|
||||||
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
|
||||||
output.write("\x1b[?25h")
|
|
||||||
input.removeListener("keypress", onKeypress)
|
|
||||||
input.setRawMode(wasRaw ?? false)
|
|
||||||
if (wasPaused) input.pause()
|
|
||||||
}
|
|
||||||
const onKeypress = (value: string | undefined, key: Key) => {
|
|
||||||
if (key.name === "up") {
|
|
||||||
selected = (selected - 1 + sessions.length) % sessions.length
|
|
||||||
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
|
|
||||||
if (selected < offset) offset = selected
|
|
||||||
}
|
|
||||||
if (key.name === "down") {
|
|
||||||
selected = (selected + 1) % sessions.length
|
|
||||||
if (selected === 0) offset = 0
|
|
||||||
if (selected >= offset + 10) offset = selected - 9
|
|
||||||
}
|
|
||||||
if (key.name === "space" || value === " ") sanitize = !sanitize
|
|
||||||
if (key.name === "return") return finish(sessions[selected])
|
|
||||||
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
|
|
||||||
render()
|
|
||||||
}
|
|
||||||
const finish = (session: SessionInfo) => {
|
|
||||||
clear()
|
|
||||||
resolveSelection?.({ session, sanitize })
|
|
||||||
}
|
|
||||||
const cancel = () => {
|
|
||||||
clear()
|
|
||||||
resolveSelection?.()
|
|
||||||
}
|
|
||||||
let resolveSelection: ((selection?: Selection) => void) | undefined
|
|
||||||
|
|
||||||
emitKeypressEvents(input)
|
|
||||||
input.setRawMode(true)
|
|
||||||
input.resume()
|
|
||||||
input.on("keypress", onKeypress)
|
|
||||||
output.write("\x1b[?25l")
|
|
||||||
render()
|
|
||||||
return new Promise<Selection | undefined>((resolve) => {
|
|
||||||
resolveSelection = resolve
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
|
|
||||||
const json = JSON.stringify(data, null, 2) + EOL
|
|
||||||
if (stdout) return json
|
|
||||||
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
|
|
||||||
await Bun.write(file, json)
|
|
||||||
return file + EOL
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { OpenCode } from "@opencode-ai/client"
|
|
||||||
import { Service } from "@opencode-ai/client/effect/service"
|
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
|
||||||
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
|
||||||
import { Effect, Option, Schema } from "effect"
|
|
||||||
import { EOL } from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
import { Commands } from "../commands"
|
|
||||||
import { Runtime } from "../../framework/runtime"
|
|
||||||
import { ServerConnection } from "../../services/server-connection"
|
|
||||||
|
|
||||||
export default Runtime.handler(
|
|
||||||
Commands.commands.import,
|
|
||||||
Effect.fn("cli.import")(function* (input) {
|
|
||||||
const text = yield* Effect.tryPromise({
|
|
||||||
try: () =>
|
|
||||||
input.file.startsWith("http://") || input.file.startsWith("https://")
|
|
||||||
? fetch(input.file).then((response) => {
|
|
||||||
if (!response.ok) throw new Error(`Failed to fetch session data: ${response.statusText}`)
|
|
||||||
return response.text()
|
|
||||||
})
|
|
||||||
: Bun.file(input.file).text(),
|
|
||||||
catch: (cause) => new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
||||||
})
|
|
||||||
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
|
|
||||||
const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
|
|
||||||
const server = yield* ServerConnection.resolve({
|
|
||||||
server: Option.getOrUndefined(input.server),
|
|
||||||
standalone: input.standalone,
|
|
||||||
})
|
|
||||||
const client = OpenCode.make({
|
|
||||||
baseUrl: server.endpoint.url,
|
|
||||||
headers: Service.headers(server.endpoint),
|
|
||||||
})
|
|
||||||
const location = yield* Effect.promise(() =>
|
|
||||||
client.location.get({
|
|
||||||
location: { directory: path.resolve(Option.getOrElse(input.directory, () => process.cwd())) },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const response = yield* Effect.promise(() =>
|
|
||||||
fetch(new URL("/api/session/import", server.endpoint.url), {
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...Service.headers(server.endpoint), "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
...encoded,
|
|
||||||
location: { directory: location.directory, workspaceID: location.workspaceID },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (response.status === 409) {
|
|
||||||
process.stderr.write(`Session already exists${EOL}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`))
|
|
||||||
const imported = yield* Schema.decodeUnknownEffect(
|
|
||||||
Schema.fromJsonString(Schema.Struct({ data: Session.Info })),
|
|
||||||
)(yield* Effect.promise(() => response.text()))
|
|
||||||
process.stdout.write(`Imported session: ${imported.data.id}${EOL}`)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
@@ -34,6 +34,7 @@ function icon(status: McpServer["status"]) {
|
|||||||
case "needs_auth":
|
case "needs_auth":
|
||||||
return "⚠"
|
return "⚠"
|
||||||
case "failed":
|
case "failed":
|
||||||
|
case "needs_client_registration":
|
||||||
return "✗"
|
return "✗"
|
||||||
default:
|
default:
|
||||||
return "○"
|
return "○"
|
||||||
@@ -44,6 +45,8 @@ function describe(status: McpServer["status"]) {
|
|||||||
switch (status.status) {
|
switch (status.status) {
|
||||||
case "needs_auth":
|
case "needs_auth":
|
||||||
return "needs authentication"
|
return "needs authentication"
|
||||||
|
case "needs_client_registration":
|
||||||
|
return `needs client registration: ${status.error}`
|
||||||
case "failed":
|
case "failed":
|
||||||
return `failed: ${status.error}`
|
return `failed: ${status.error}`
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
list: () => import("./commands/handlers/plugin/list"),
|
list: () => import("./commands/handlers/plugin/list"),
|
||||||
},
|
},
|
||||||
models: () => import("./commands/handlers/models"),
|
models: () => import("./commands/handlers/models"),
|
||||||
export: () => import("./commands/handlers/export"),
|
|
||||||
import: () => import("./commands/handlers/import"),
|
|
||||||
mini: () => import("./commands/handlers/mini"),
|
mini: () => import("./commands/handlers/mini"),
|
||||||
run: () => import("./commands/handlers/run"),
|
run: () => import("./commands/handlers/run"),
|
||||||
pair: () => import("./commands/handlers/pair"),
|
pair: () => import("./commands/handlers/pair"),
|
||||||
|
|||||||
@@ -94,9 +94,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
|||||||
prepare: async (next) => {
|
prepare: async (next) => {
|
||||||
const selected =
|
const selected =
|
||||||
next.model ??
|
next.model ??
|
||||||
(await client.model
|
(options.variant
|
||||||
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
|
? await client.model
|
||||||
.then((result) => result.data))
|
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
|
||||||
|
.then((result) => result.data)
|
||||||
|
: undefined)
|
||||||
const model = selected
|
const model = selected
|
||||||
? {
|
? {
|
||||||
providerID: selected.providerID,
|
providerID: selected.providerID,
|
||||||
@@ -106,12 +108,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
|||||||
: undefined
|
: undefined
|
||||||
if ((options.variant ?? explicit?.variant) && !model)
|
if ((options.variant ?? explicit?.variant) && !model)
|
||||||
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
|
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
|
||||||
const agent =
|
return { model, agent: next.agent }
|
||||||
next.agent ??
|
|
||||||
(await client.agent
|
|
||||||
.list({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
|
|
||||||
.then((result) => result.data.find((item) => item.mode !== "subagent" && !item.hidden)?.id))
|
|
||||||
return { model, agent }
|
|
||||||
},
|
},
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
if (!(error instanceof RunTargetError)) throw error
|
if (!(error instanceof RunTargetError)) throw error
|
||||||
|
|||||||
@@ -56,16 +56,13 @@ export async function resolveSessionTarget(input: {
|
|||||||
agent: input.agent ?? selected?.agent,
|
agent: input.agent ?? selected?.agent,
|
||||||
signal: input.signal,
|
signal: input.signal,
|
||||||
})
|
})
|
||||||
if (!selected && (!prepared.agent || !prepared.model)) {
|
|
||||||
throw new SessionTargetMutationError(new Error("Creating a session requires an agent and model"))
|
|
||||||
}
|
|
||||||
const session =
|
const session =
|
||||||
selected ??
|
selected ??
|
||||||
(await input.client.session
|
(await input.client.session
|
||||||
.create(
|
.create(
|
||||||
{
|
{
|
||||||
agent: prepared.agent!,
|
agent: prepared.agent,
|
||||||
model: prepared.model!,
|
model: prepared.model,
|
||||||
location: { directory: location.directory, workspaceID: location.workspaceID },
|
location: { directory: location.directory, workspaceID: location.workspaceID },
|
||||||
},
|
},
|
||||||
...requestOptions(input.signal),
|
...requestOptions(input.signal),
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import fs from "node:fs/promises"
|
|
||||||
import os from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
import { OPENCODE_VERSION } from "../src/version"
|
|
||||||
import { writeExport } from "../src/commands/handlers/export"
|
|
||||||
|
|
||||||
const info = {
|
|
||||||
id: "ses_export_test",
|
|
||||||
projectID: "global",
|
|
||||||
cost: 0,
|
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
time: { created: 1, updated: 2 },
|
|
||||||
title: "Exported session",
|
|
||||||
location: { directory: "/project" },
|
|
||||||
}
|
|
||||||
const transfer = {
|
|
||||||
info,
|
|
||||||
messages: [
|
|
||||||
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
|
|
||||||
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const sanitizedTransfer = {
|
|
||||||
info: {
|
|
||||||
...info,
|
|
||||||
title: "[redacted:session-title:ses_export_test]",
|
|
||||||
location: { directory: "/[redacted:session-directory:ses_export_test]" },
|
|
||||||
},
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: "msg_first",
|
|
||||||
type: "user",
|
|
||||||
text: "[redacted:text:msg_first]",
|
|
||||||
time: { created: 1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "msg_second",
|
|
||||||
type: "user",
|
|
||||||
text: "[redacted:text:msg_second]",
|
|
||||||
time: { created: 2 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const health = () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
|
||||||
|
|
||||||
function run(args: string[], stdin?: string) {
|
|
||||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
|
||||||
cwd: path.join(import.meta.dir, ".."),
|
|
||||||
stdin: stdin === undefined ? undefined : new Blob([stdin]),
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
})
|
|
||||||
return Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
|
|
||||||
}
|
|
||||||
|
|
||||||
test("export is raw by default and supports explicit sanitization", async () => {
|
|
||||||
const sanitization: string[] = []
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch(request) {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
if (url.pathname === "/api/health") return health()
|
|
||||||
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
|
|
||||||
if (url.pathname === `/api/session/${info.id}/export`) {
|
|
||||||
sanitization.push(url.searchParams.get("sanitize") ?? "")
|
|
||||||
return Response.json({ data: url.searchParams.get("sanitize") === "true" ? sanitizedTransfer : transfer })
|
|
||||||
}
|
|
||||||
return new Response("Not found", { status: 404 })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()])
|
|
||||||
const exported = JSON.parse(stdout)
|
|
||||||
|
|
||||||
expect(exitCode).toBe(0)
|
|
||||||
expect(exported).toEqual(transfer)
|
|
||||||
|
|
||||||
const [sanitized, , sanitizedExitCode] = await run([
|
|
||||||
"export",
|
|
||||||
"-s",
|
|
||||||
info.id,
|
|
||||||
"--sanitize",
|
|
||||||
"--server",
|
|
||||||
server.url.toString(),
|
|
||||||
])
|
|
||||||
expect(sanitizedExitCode).toBe(0)
|
|
||||||
expect(JSON.parse(sanitized)).toEqual(sanitizedTransfer)
|
|
||||||
expect(sanitization).toEqual(["false", "true"])
|
|
||||||
} finally {
|
|
||||||
await server.stop(true)
|
|
||||||
}
|
|
||||||
}, 15_000)
|
|
||||||
|
|
||||||
test("export reports an empty session list without a stack trace", async () => {
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch(request) {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
if (url.pathname === "/api/health") return health()
|
|
||||||
if (url.pathname === "/api/location") {
|
|
||||||
return Response.json({
|
|
||||||
directory: "/project",
|
|
||||||
project: { id: "global", directory: "/project", canonical: "/project" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/session") return Response.json({ data: [], cursor: {} })
|
|
||||||
return new Response("Not found", { status: 404 })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
|
|
||||||
|
|
||||||
expect(exitCode).toBe(0)
|
|
||||||
expect(stdout).toBe("")
|
|
||||||
expect(stderr).toBe(`No sessions found${os.EOL}`)
|
|
||||||
} finally {
|
|
||||||
await server.stop(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("interactive export writes a temporary JSON file", async () => {
|
|
||||||
const output = await writeExport(transfer, info.id, false)
|
|
||||||
const file = output.trim()
|
|
||||||
|
|
||||||
try {
|
|
||||||
expect(path.dirname(file)).toBe(os.tmpdir())
|
|
||||||
expect(await Bun.file(file).json()).toEqual(transfer)
|
|
||||||
} finally {
|
|
||||||
await fs.rm(file, { force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("import validates a file and sends it to the resolved location", async () => {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-"))
|
|
||||||
const file = path.join(root, "session.json")
|
|
||||||
await fs.writeFile(file, JSON.stringify(transfer))
|
|
||||||
let imported: unknown
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
async fetch(request) {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
if (url.pathname === "/api/health") return health()
|
|
||||||
if (url.pathname === "/api/location") {
|
|
||||||
return Response.json({
|
|
||||||
directory: root,
|
|
||||||
project: { id: "global", directory: root, canonical: root },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/session/import") {
|
|
||||||
imported = await request.json()
|
|
||||||
return Response.json({ data: { ...info, location: { directory: root } } })
|
|
||||||
}
|
|
||||||
return new Response("Not found", { status: 404 })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [stdout, , exitCode] = await run([
|
|
||||||
"import",
|
|
||||||
file,
|
|
||||||
"--directory",
|
|
||||||
root,
|
|
||||||
"--server",
|
|
||||||
server.url.toString(),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(exitCode).toBe(0)
|
|
||||||
expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`)
|
|
||||||
expect(imported).toEqual({ ...transfer, location: { directory: root } })
|
|
||||||
} finally {
|
|
||||||
await server.stop(true)
|
|
||||||
await fs.rm(root, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("import reports an existing session without a stack trace", async () => {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-conflict-"))
|
|
||||||
const file = path.join(root, "session.json")
|
|
||||||
await fs.writeFile(file, JSON.stringify(transfer))
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch(request) {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
if (url.pathname === "/api/health") return health()
|
|
||||||
if (url.pathname === "/api/location") {
|
|
||||||
return Response.json({
|
|
||||||
directory: root,
|
|
||||||
project: { id: "global", directory: root, canonical: root },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/session/import") return new Response("Conflict", { status: 409 })
|
|
||||||
return new Response("Not found", { status: 404 })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [stdout, stderr, exitCode] = await run(["import", file, "--server", server.url.toString()])
|
|
||||||
|
|
||||||
expect(exitCode).toBe(0)
|
|
||||||
expect(stdout).toBe("")
|
|
||||||
expect(stderr).toBe(`Session already exists${os.EOL}`)
|
|
||||||
} finally {
|
|
||||||
await server.stop(true)
|
|
||||||
await fs.rm(root, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -61,11 +61,7 @@ describe("session target resolver", () => {
|
|||||||
spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1"))
|
spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1"))
|
||||||
const create = spyOn(client.session, "create").mockImplementation(async (input) => {
|
const create = spyOn(client.session, "create").mockImplementation(async (input) => {
|
||||||
order.push("create")
|
order.push("create")
|
||||||
expect(input).toMatchObject({
|
expect(input).toMatchObject({ agent: "prepared", location: { directory: "/server", workspaceID: "work_1" } })
|
||||||
agent: "prepared",
|
|
||||||
model: { providerID: "openai", id: "gpt-5" },
|
|
||||||
location: { directory: "/server", workspaceID: "work_1" },
|
|
||||||
})
|
|
||||||
return session("ses_fresh", "/server", "work_1")
|
return session("ses_fresh", "/server", "work_1")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -75,22 +71,20 @@ describe("session target resolver", () => {
|
|||||||
prepare: async (input) => {
|
prepare: async (input) => {
|
||||||
order.push("prepare")
|
order.push("prepare")
|
||||||
expect(input.location.workspaceID).toBe("work_1")
|
expect(input.location.workspaceID).toBe("work_1")
|
||||||
return { model: { providerID: "openai", id: "gpt-5" }, agent: "prepared" }
|
return { model: input.model, agent: "prepared" }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(create).toHaveBeenCalledTimes(1)
|
expect(create).toHaveBeenCalledTimes(1)
|
||||||
expect(order).toEqual(["prepare", "create"])
|
expect(order).toEqual(["prepare", "create"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("requires an explicit agent and model for a fresh Session", async () => {
|
test("uses the agent resolved by the server for a fresh Session", async () => {
|
||||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
||||||
const create = spyOn(client.session, "create")
|
spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" })
|
||||||
|
|
||||||
await expect(resolveSessionTarget({ client, prepare })).rejects.toThrow(
|
const target = await resolveSessionTarget({ client, prepare })
|
||||||
"Creating a session requires an agent and model",
|
expect(target.agent).toBe("review")
|
||||||
)
|
|
||||||
expect(create).not.toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not retry an ambiguous Session creation", async () => {
|
test("does not retry an ambiguous Session creation", async () => {
|
||||||
|
|||||||
@@ -120,61 +120,49 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
|
|||||||
export type Endpoint5_1Input = {
|
export type Endpoint5_1Input = {
|
||||||
readonly id?: Session.ID | undefined
|
readonly id?: Session.ID | undefined
|
||||||
readonly title?: string | undefined
|
readonly title?: string | undefined
|
||||||
readonly agent: Agent.ID
|
readonly agent?: Agent.ID | undefined
|
||||||
readonly model: Model.Ref
|
readonly model?: Model.Ref | undefined
|
||||||
readonly location?: Location.Ref | undefined
|
readonly location?: Location.Ref | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_1Output = Session.Info
|
export type Endpoint5_1Output = Session.Info
|
||||||
export type SessionCreateOperation<E = never> = (input: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
|
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
|
||||||
|
|
||||||
export type Endpoint5_2Input = {
|
export type Endpoint5_2Output = { readonly [x: Session.ID]: { readonly type: "running" } }
|
||||||
readonly info: Session.Info
|
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_2Output, E>
|
||||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
|
||||||
readonly location?: Location.Ref | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint5_2Output = Session.Info
|
|
||||||
export type SessionImportOperation<E = never> = (input: Endpoint5_2Input) => Effect.Effect<Endpoint5_2Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_3Input = { readonly sessionID: Session.ID; readonly sanitize?: boolean | undefined }
|
export type Endpoint5_3Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_3Output = { readonly info: Session.Info; readonly messages: ReadonlyArray<SessionMessage.Info> }
|
export type Endpoint5_3Output = Session.Info
|
||||||
export type SessionExportOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
|
export type SessionGetOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
|
||||||
|
|
||||||
export type Endpoint5_4Output = { readonly [x: Session.ID]: { readonly type: "running" } }
|
export type Endpoint5_4Input = { readonly sessionID: Session.ID }
|
||||||
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_4Output, E>
|
export type Endpoint5_4Output = void
|
||||||
|
export type SessionRemoveOperation<E = never> = (input: Endpoint5_4Input) => Effect.Effect<Endpoint5_4Output, E>
|
||||||
|
|
||||||
export type Endpoint5_5Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||||
export type Endpoint5_5Output = Session.Info
|
export type Endpoint5_5Output = Session.Info
|
||||||
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
export type SessionForkOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
||||||
|
|
||||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||||
export type Endpoint5_6Output = void
|
export type Endpoint5_6Output = void
|
||||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||||
|
|
||||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||||
export type Endpoint5_7Output = Session.Info
|
export type Endpoint5_7Output = void
|
||||||
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||||
|
|
||||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||||
export type Endpoint5_8Output = void
|
export type Endpoint5_8Output = void
|
||||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||||
|
|
||||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
export type Endpoint5_9Input = {
|
||||||
export type Endpoint5_9Output = void
|
|
||||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
|
|
||||||
export type Endpoint5_10Output = void
|
|
||||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_11Input = {
|
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
readonly workspaceID?: Workspace.ID | undefined
|
readonly workspaceID?: Workspace.ID | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_11Output = void
|
export type Endpoint5_9Output = void
|
||||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||||
|
|
||||||
export type Endpoint5_12Input = {
|
export type Endpoint5_10Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
readonly id?: SessionMessage.ID | undefined
|
||||||
readonly text: string
|
readonly text: string
|
||||||
@@ -184,10 +172,10 @@ export type Endpoint5_12Input = {
|
|||||||
readonly delivery?: "steer" | "queue" | undefined
|
readonly delivery?: "steer" | "queue" | undefined
|
||||||
readonly resume?: boolean | undefined
|
readonly resume?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_12Output = SessionPending.User
|
export type Endpoint5_10Output = SessionPending.User
|
||||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||||
|
|
||||||
export type Endpoint5_13Input = {
|
export type Endpoint5_11Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
readonly id?: SessionMessage.ID | undefined
|
||||||
readonly command: string
|
readonly command: string
|
||||||
@@ -199,19 +187,19 @@ export type Endpoint5_13Input = {
|
|||||||
readonly delivery?: "steer" | "queue" | undefined
|
readonly delivery?: "steer" | "queue" | undefined
|
||||||
readonly resume?: boolean | undefined
|
readonly resume?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_13Output = SessionPending.User
|
export type Endpoint5_11Output = SessionPending.User
|
||||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||||
|
|
||||||
export type Endpoint5_14Input = {
|
export type Endpoint5_12Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
readonly id?: SessionMessage.ID | undefined
|
||||||
readonly skill: Skill.ID
|
readonly skill: Skill.ID
|
||||||
readonly resume?: boolean | undefined
|
readonly resume?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_14Output = void
|
export type Endpoint5_12Output = void
|
||||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||||
|
|
||||||
export type Endpoint5_15Input = {
|
export type Endpoint5_13Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
readonly id?: SessionMessage.ID | undefined
|
||||||
readonly text: string
|
readonly text: string
|
||||||
@@ -220,81 +208,81 @@ export type Endpoint5_15Input = {
|
|||||||
readonly delivery?: "steer" | "queue" | undefined
|
readonly delivery?: "steer" | "queue" | undefined
|
||||||
readonly resume?: boolean | undefined
|
readonly resume?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_15Output = SessionPending.Synthetic
|
export type Endpoint5_13Output = SessionPending.Synthetic
|
||||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||||
|
|
||||||
export type Endpoint5_16Input = {
|
export type Endpoint5_14Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: Event.ID | undefined
|
readonly id?: Event.ID | undefined
|
||||||
readonly command: string
|
readonly command: string
|
||||||
}
|
}
|
||||||
|
export type Endpoint5_14Output = void
|
||||||
|
export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_15Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
|
||||||
|
export type Endpoint5_15Output = SessionPending.Compaction
|
||||||
|
export type SessionCompactOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_16Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_16Output = void
|
export type Endpoint5_16Output = void
|
||||||
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
export type SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||||
|
|
||||||
export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
|
export type Endpoint5_17Input = {
|
||||||
export type Endpoint5_17Output = SessionPending.Compaction
|
|
||||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
|
||||||
export type Endpoint5_18Output = void
|
|
||||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_19Input = {
|
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly messageID: SessionMessage.ID
|
readonly messageID: SessionMessage.ID
|
||||||
readonly files?: boolean | undefined
|
readonly files?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_19Output = Session.Revert
|
export type Endpoint5_17Output = Session.Revert
|
||||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
||||||
|
export type Endpoint5_18Output = void
|
||||||
|
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
|
||||||
|
export type Endpoint5_19Output = void
|
||||||
|
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||||
|
|
||||||
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_20Output = void
|
export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
|
||||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||||
|
|
||||||
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_21Output = void
|
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
|
||||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||||
|
|
||||||
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
|
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
|
||||||
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
|
||||||
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
|
|
||||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
|
||||||
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
|
|
||||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||||
input: Endpoint5_24Input,
|
input: Endpoint5_22Input,
|
||||||
) => Effect.Effect<Endpoint5_24Output, E>
|
) => Effect.Effect<Endpoint5_22Output, E>
|
||||||
|
|
||||||
export type Endpoint5_25Input = {
|
export type Endpoint5_23Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly key: InstructionEntry.Key
|
readonly key: InstructionEntry.Key
|
||||||
readonly value: Schema.Json
|
readonly value: Schema.Json
|
||||||
}
|
}
|
||||||
export type Endpoint5_25Output = void
|
export type Endpoint5_23Output = void
|
||||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||||
input: Endpoint5_25Input,
|
input: Endpoint5_23Input,
|
||||||
) => Effect.Effect<Endpoint5_25Output, E>
|
) => Effect.Effect<Endpoint5_23Output, E>
|
||||||
|
|
||||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||||
export type Endpoint5_26Output = void
|
export type Endpoint5_24Output = void
|
||||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||||
input: Endpoint5_26Input,
|
input: Endpoint5_24Input,
|
||||||
) => Effect.Effect<Endpoint5_26Output, E>
|
) => Effect.Effect<Endpoint5_24Output, E>
|
||||||
|
|
||||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||||
export type Endpoint5_27Output = { readonly text: string }
|
export type Endpoint5_25Output = { readonly text: string }
|
||||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||||
|
|
||||||
export type Endpoint5_28Input = {
|
export type Endpoint5_26Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly after?: Event.Seq | undefined
|
readonly after?: Event.Seq | undefined
|
||||||
readonly follow?: boolean | undefined
|
readonly follow?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_28Output =
|
export type Endpoint5_26Output =
|
||||||
| (
|
| (
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
@@ -862,25 +850,23 @@ export type Endpoint5_28Output =
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
| EventLog.Synced
|
| EventLog.Synced
|
||||||
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
|
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
|
||||||
|
|
||||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_29Output = void
|
export type Endpoint5_27Output = void
|
||||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||||
|
|
||||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_30Output = void
|
export type Endpoint5_28Output = void
|
||||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
|
||||||
|
|
||||||
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||||
export type Endpoint5_31Output = SessionMessage.Info
|
export type Endpoint5_29Output = SessionMessage.Info
|
||||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
||||||
|
|
||||||
export interface SessionApi<E = never> {
|
export interface SessionApi<E = never> {
|
||||||
readonly list: SessionListOperation<E>
|
readonly list: SessionListOperation<E>
|
||||||
readonly create: SessionCreateOperation<E>
|
readonly create: SessionCreateOperation<E>
|
||||||
readonly import: SessionImportOperation<E>
|
|
||||||
readonly export: SessionExportOperation<E>
|
|
||||||
readonly active: SessionActiveOperation<E>
|
readonly active: SessionActiveOperation<E>
|
||||||
readonly get: SessionGetOperation<E>
|
readonly get: SessionGetOperation<E>
|
||||||
readonly remove: SessionRemoveOperation<E>
|
readonly remove: SessionRemoveOperation<E>
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import type {
|
|||||||
Endpoint5_0Output,
|
Endpoint5_0Output,
|
||||||
Endpoint5_1Input,
|
Endpoint5_1Input,
|
||||||
Endpoint5_1Output,
|
Endpoint5_1Output,
|
||||||
Endpoint5_2Input,
|
|
||||||
Endpoint5_2Output,
|
Endpoint5_2Output,
|
||||||
Endpoint5_3Input,
|
Endpoint5_3Input,
|
||||||
Endpoint5_3Output,
|
Endpoint5_3Output,
|
||||||
|
Endpoint5_4Input,
|
||||||
Endpoint5_4Output,
|
Endpoint5_4Output,
|
||||||
Endpoint5_5Input,
|
Endpoint5_5Input,
|
||||||
Endpoint5_5Output,
|
Endpoint5_5Output,
|
||||||
@@ -76,10 +76,6 @@ import type {
|
|||||||
Endpoint5_28Output,
|
Endpoint5_28Output,
|
||||||
Endpoint5_29Input,
|
Endpoint5_29Input,
|
||||||
Endpoint5_29Output,
|
Endpoint5_29Output,
|
||||||
Endpoint5_30Input,
|
|
||||||
Endpoint5_30Output,
|
|
||||||
Endpoint5_31Input,
|
|
||||||
Endpoint5_31Output,
|
|
||||||
Endpoint6_0Input,
|
Endpoint6_0Input,
|
||||||
Endpoint6_0Output,
|
Endpoint6_0Output,
|
||||||
Endpoint7_0Input,
|
Endpoint7_0Input,
|
||||||
@@ -305,15 +301,15 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
|
|||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input: Endpoint5_1Input) =>
|
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) =>
|
||||||
preserveEffect<Endpoint5_1Output>()(
|
preserveEffect<Endpoint5_1Output>()(
|
||||||
raw["session.create"]({
|
raw["session.create"]({
|
||||||
payload: {
|
payload: {
|
||||||
id: input["id"],
|
id: input?.["id"],
|
||||||
title: input["title"],
|
title: input?.["title"],
|
||||||
agent: input["agent"],
|
agent: input?.["agent"],
|
||||||
model: input["model"],
|
model: input?.["model"],
|
||||||
location: input["location"],
|
location: input?.["location"],
|
||||||
},
|
},
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
@@ -321,11 +317,9 @@ const Endpoint5_1 = (raw: RawClient["server.session"]) => (input: Endpoint5_1Inp
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Input) =>
|
const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
|
||||||
preserveEffect<Endpoint5_2Output>()(
|
preserveEffect<Endpoint5_2Output>()(
|
||||||
raw["session.import"]({
|
raw["session.active"]({}).pipe(
|
||||||
payload: { info: input["info"], messages: input["messages"], location: input["location"] },
|
|
||||||
}).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
@@ -333,23 +327,20 @@ const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Inp
|
|||||||
|
|
||||||
const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
|
const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
|
||||||
preserveEffect<Endpoint5_3Output>()(
|
preserveEffect<Endpoint5_3Output>()(
|
||||||
raw["session.export"]({ params: { sessionID: input["sessionID"] }, query: { sanitize: input["sanitize"] } }).pipe(
|
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_4 = (raw: RawClient["server.session"]) => () =>
|
const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Input) =>
|
||||||
preserveEffect<Endpoint5_4Output>()(
|
preserveEffect<Endpoint5_4Output>()(
|
||||||
raw["session.active"]({}).pipe(
|
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
|
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
|
||||||
preserveEffect<Endpoint5_5Output>()(
|
preserveEffect<Endpoint5_5Output>()(
|
||||||
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
@@ -357,48 +348,35 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
|
|||||||
|
|
||||||
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
|
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
|
||||||
preserveEffect<Endpoint5_6Output>()(
|
preserveEffect<Endpoint5_6Output>()(
|
||||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
|
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
|
||||||
preserveEffect<Endpoint5_7Output>()(
|
preserveEffect<Endpoint5_7Output>()(
|
||||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
||||||
preserveEffect<Endpoint5_8Output>()(
|
preserveEffect<Endpoint5_8Output>()(
|
||||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
||||||
preserveEffect<Endpoint5_9Output>()(
|
preserveEffect<Endpoint5_9Output>()(
|
||||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
|
||||||
preserveEffect<Endpoint5_10Output>()(
|
|
||||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
|
||||||
preserveEffect<Endpoint5_11Output>()(
|
|
||||||
raw["session.move"]({
|
raw["session.move"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
|
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
||||||
preserveEffect<Endpoint5_12Output>()(
|
preserveEffect<Endpoint5_10Output>()(
|
||||||
raw["session.prompt"]({
|
raw["session.prompt"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: {
|
payload: {
|
||||||
@@ -416,8 +394,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
||||||
preserveEffect<Endpoint5_13Output>()(
|
preserveEffect<Endpoint5_11Output>()(
|
||||||
raw["session.command"]({
|
raw["session.command"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: {
|
payload: {
|
||||||
@@ -437,16 +415,16 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||||
preserveEffect<Endpoint5_14Output>()(
|
preserveEffect<Endpoint5_12Output>()(
|
||||||
raw["session.skill"]({
|
raw["session.skill"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||||
preserveEffect<Endpoint5_15Output>()(
|
preserveEffect<Endpoint5_13Output>()(
|
||||||
raw["session.synthetic"]({
|
raw["session.synthetic"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: {
|
payload: {
|
||||||
@@ -463,29 +441,29 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||||
preserveEffect<Endpoint5_16Output>()(
|
preserveEffect<Endpoint5_14Output>()(
|
||||||
raw["session.shell"]({
|
raw["session.shell"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { id: input["id"], command: input["command"] },
|
payload: { id: input["id"], command: input["command"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||||
preserveEffect<Endpoint5_17Output>()(
|
preserveEffect<Endpoint5_15Output>()(
|
||||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
|
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||||
preserveEffect<Endpoint5_18Output>()(
|
preserveEffect<Endpoint5_16Output>()(
|
||||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||||
preserveEffect<Endpoint5_19Output>()(
|
preserveEffect<Endpoint5_17Output>()(
|
||||||
raw["session.revert.stage"]({
|
raw["session.revert.stage"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { messageID: input["messageID"], files: input["files"] },
|
payload: { messageID: input["messageID"], files: input["files"] },
|
||||||
@@ -495,19 +473,35 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||||
|
preserveEffect<Endpoint5_18Output>()(
|
||||||
|
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||||
|
preserveEffect<Endpoint5_19Output>()(
|
||||||
|
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
|
)
|
||||||
|
|
||||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||||
preserveEffect<Endpoint5_20Output>()(
|
preserveEffect<Endpoint5_20Output>()(
|
||||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
||||||
preserveEffect<Endpoint5_21Output>()(
|
preserveEffect<Endpoint5_21Output>()(
|
||||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
||||||
preserveEffect<Endpoint5_22Output>()(
|
preserveEffect<Endpoint5_22Output>()(
|
||||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
@@ -515,45 +509,29 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
|
|||||||
|
|
||||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||||
preserveEffect<Endpoint5_23Output>()(
|
preserveEffect<Endpoint5_23Output>()(
|
||||||
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
|
||||||
preserveEffect<Endpoint5_24Output>()(
|
|
||||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
|
||||||
preserveEffect<Endpoint5_25Output>()(
|
|
||||||
raw["session.instructions.entry.put"]({
|
raw["session.instructions.entry.put"]({
|
||||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||||
payload: { value: input["value"] },
|
payload: { value: input["value"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||||
preserveEffect<Endpoint5_26Output>()(
|
preserveEffect<Endpoint5_24Output>()(
|
||||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||||
preserveEffect<Endpoint5_27Output>()(
|
preserveEffect<Endpoint5_25Output>()(
|
||||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||||
preserveStream<Endpoint5_28Output>()(
|
preserveStream<Endpoint5_26Output>()(
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
raw["session.log"]({
|
raw["session.log"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
@@ -565,18 +543,18 @@ const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||||
preserveEffect<Endpoint5_29Output>()(
|
preserveEffect<Endpoint5_27Output>()(
|
||||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||||
preserveEffect<Endpoint5_30Output>()(
|
preserveEffect<Endpoint5_28Output>()(
|
||||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||||
preserveEffect<Endpoint5_31Output>()(
|
preserveEffect<Endpoint5_29Output>()(
|
||||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
@@ -586,32 +564,30 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
|
|||||||
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||||
list: Endpoint5_0(raw),
|
list: Endpoint5_0(raw),
|
||||||
create: Endpoint5_1(raw),
|
create: Endpoint5_1(raw),
|
||||||
import: Endpoint5_2(raw),
|
active: Endpoint5_2(raw),
|
||||||
export: Endpoint5_3(raw),
|
get: Endpoint5_3(raw),
|
||||||
active: Endpoint5_4(raw),
|
remove: Endpoint5_4(raw),
|
||||||
get: Endpoint5_5(raw),
|
fork: Endpoint5_5(raw),
|
||||||
remove: Endpoint5_6(raw),
|
switchAgent: Endpoint5_6(raw),
|
||||||
fork: Endpoint5_7(raw),
|
switchModel: Endpoint5_7(raw),
|
||||||
switchAgent: Endpoint5_8(raw),
|
rename: Endpoint5_8(raw),
|
||||||
switchModel: Endpoint5_9(raw),
|
move: Endpoint5_9(raw),
|
||||||
rename: Endpoint5_10(raw),
|
prompt: Endpoint5_10(raw),
|
||||||
move: Endpoint5_11(raw),
|
command: Endpoint5_11(raw),
|
||||||
prompt: Endpoint5_12(raw),
|
skill: Endpoint5_12(raw),
|
||||||
command: Endpoint5_13(raw),
|
synthetic: Endpoint5_13(raw),
|
||||||
skill: Endpoint5_14(raw),
|
shell: Endpoint5_14(raw),
|
||||||
synthetic: Endpoint5_15(raw),
|
compact: Endpoint5_15(raw),
|
||||||
shell: Endpoint5_16(raw),
|
wait: Endpoint5_16(raw),
|
||||||
compact: Endpoint5_17(raw),
|
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
|
||||||
wait: Endpoint5_18(raw),
|
context: Endpoint5_20(raw),
|
||||||
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
pending: { list: Endpoint5_21(raw) },
|
||||||
context: Endpoint5_22(raw),
|
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
|
||||||
pending: { list: Endpoint5_23(raw) },
|
generate: Endpoint5_25(raw),
|
||||||
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
|
log: Endpoint5_26(raw),
|
||||||
generate: Endpoint5_27(raw),
|
interrupt: Endpoint5_27(raw),
|
||||||
log: Endpoint5_28(raw),
|
background: Endpoint5_28(raw),
|
||||||
interrupt: Endpoint5_29(raw),
|
message: Endpoint5_29(raw),
|
||||||
background: Endpoint5_30(raw),
|
|
||||||
message: Endpoint5_31(raw),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ import type {
|
|||||||
SessionListOutput,
|
SessionListOutput,
|
||||||
SessionCreateInput,
|
SessionCreateInput,
|
||||||
SessionCreateOutput,
|
SessionCreateOutput,
|
||||||
SessionImportInput,
|
|
||||||
SessionImportOutput,
|
|
||||||
SessionExportInput,
|
|
||||||
SessionExportOutput,
|
|
||||||
SessionActiveOutput,
|
SessionActiveOutput,
|
||||||
SessionGetInput,
|
SessionGetInput,
|
||||||
SessionGetOutput,
|
SessionGetOutput,
|
||||||
@@ -464,17 +460,17 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
create: (input: SessionCreateInput, requestOptions?: RequestOptions) =>
|
create: (input?: SessionCreateInput, requestOptions?: RequestOptions) =>
|
||||||
request<{ readonly data: SessionCreateOutput }>(
|
request<{ readonly data: SessionCreateOutput }>(
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/session`,
|
path: `/api/session`,
|
||||||
body: {
|
body: {
|
||||||
id: input["id"],
|
id: input?.["id"],
|
||||||
title: input["title"],
|
title: input?.["title"],
|
||||||
agent: input["agent"],
|
agent: input?.["agent"],
|
||||||
model: input["model"],
|
model: input?.["model"],
|
||||||
location: input["location"],
|
location: input?.["location"],
|
||||||
},
|
},
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [401, 400],
|
declaredStatuses: [401, 400],
|
||||||
@@ -482,30 +478,6 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
import: (input: SessionImportInput, requestOptions?: RequestOptions) =>
|
|
||||||
request<{ readonly data: SessionImportOutput }>(
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: `/api/session/import`,
|
|
||||||
body: { info: input["info"], messages: input["messages"], location: input["location"] },
|
|
||||||
successStatus: 200,
|
|
||||||
declaredStatuses: [409, 401, 400],
|
|
||||||
empty: false,
|
|
||||||
},
|
|
||||||
requestOptions,
|
|
||||||
).then((value) => value.data),
|
|
||||||
export: (input: SessionExportInput, requestOptions?: RequestOptions) =>
|
|
||||||
request<{ readonly data: SessionExportOutput }>(
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/export`,
|
|
||||||
query: { sanitize: input["sanitize"] },
|
|
||||||
successStatus: 200,
|
|
||||||
declaredStatuses: [404, 500, 401, 400],
|
|
||||||
empty: false,
|
|
||||||
},
|
|
||||||
requestOptions,
|
|
||||||
).then((value) => value.data),
|
|
||||||
active: (requestOptions?: RequestOptions) =>
|
active: (requestOptions?: RequestOptions) =>
|
||||||
request<{ readonly data: SessionActiveOutput }>(
|
request<{ readonly data: SessionActiveOutput }>(
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -181,8 +181,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
const page = yield* client.session.list({ limit: 10 })
|
const page = yield* client.session.list({ limit: 10 })
|
||||||
const active = yield* client.session.active()
|
const active = yield* client.session.active()
|
||||||
const created = yield* client.session.create({
|
const created = yield* client.session.create({
|
||||||
agent: Agent.ID.make("build"),
|
|
||||||
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
|
|
||||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
||||||
})
|
})
|
||||||
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
|
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
|
||||||
|
|||||||
@@ -454,11 +454,7 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
|
|
||||||
const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
|
const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
|
||||||
const active = await client.session.active()
|
const active = await client.session.active()
|
||||||
const created = await client.session.create({
|
const created = await client.session.create({ location: { directory: "/tmp/project" } })
|
||||||
agent: "build",
|
|
||||||
model: { id: "claude", providerID: "anthropic" },
|
|
||||||
location: { directory: "/tmp/project" },
|
|
||||||
})
|
|
||||||
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
|
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||||
await client.session.switchModel({
|
await client.session.switchModel({
|
||||||
sessionID: "ses_test",
|
sessionID: "ses_test",
|
||||||
@@ -532,7 +528,7 @@ test("middleware errors remain declared client errors", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.session.create({ agent: "build", model: { id: "claude", providerID: "anthropic" } })
|
await client.session.create({})
|
||||||
throw new Error("Expected request to fail")
|
throw new Error("Expected request to fail")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(isUnauthorizedError(error)).toBe(true)
|
expect(isUnauthorizedError(error)).toBe(true)
|
||||||
|
|||||||
+17
-35
@@ -24,7 +24,8 @@ import { Global } from "@opencode-ai/util/global"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { ConfigVariable } from "./config/variable"
|
import { ConfigVariable } from "./config/variable"
|
||||||
import { ConfigNormalize } from "./config/normalize"
|
import { ConfigV1 } from "./v1/config/config"
|
||||||
|
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||||
import { WellKnown } from "./wellknown"
|
import { WellKnown } from "./wellknown"
|
||||||
|
|
||||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||||
@@ -92,43 +93,24 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
const reloadLock = Semaphore.makeUnsafe(1)
|
const reloadLock = Semaphore.makeUnsafe(1)
|
||||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||||
|
|
||||||
|
const parseInfo = (text: string) => {
|
||||||
const errors: ParseError[] = []
|
const errors: ParseError[] = []
|
||||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||||
if (errors.length) {
|
if (errors.length) return
|
||||||
yield* Effect.logWarning("configuration normalization diagnostic", {
|
return Option.getOrUndefined(
|
||||||
source,
|
ConfigMigrateV1.isV1(input)
|
||||||
path: "$",
|
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
||||||
kind: "invalid",
|
: decodeInfo(input),
|
||||||
action: "rejected malformed JSON or JSONC document",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = ConfigNormalize.normalize(input)
|
|
||||||
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
|
|
||||||
Effect.logWarning("configuration normalization diagnostic", {
|
|
||||||
source,
|
|
||||||
path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
|
|
||||||
kind: diagnostic.kind,
|
|
||||||
action: diagnostic.message,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
if (result.type === "rejected") return
|
}
|
||||||
const info = Option.getOrUndefined(decodeInfo(result.encoded))
|
|
||||||
if (info) return info
|
|
||||||
yield* Effect.logWarning("configuration normalization diagnostic", {
|
|
||||||
source,
|
|
||||||
path: "$",
|
|
||||||
kind: "invalid",
|
|
||||||
action: "rejected canonical configuration after final validation",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||||
const text = yield* fs.readFileStringSafe(filepath)
|
const text = yield* fs.readFileStringSafe(filepath)
|
||||||
if (text === undefined) return
|
if (!text) return
|
||||||
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
||||||
const info = yield* parseInfo(substituted, filepath)
|
const info = parseInfo(substituted)
|
||||||
if (!info) return
|
if (!info) return
|
||||||
return new Document({ type: "document", path: filepath, info })
|
return new Document({ type: "document", path: filepath, info })
|
||||||
})
|
})
|
||||||
@@ -159,7 +141,7 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
text: JSON.stringify(config),
|
text: JSON.stringify(config),
|
||||||
env: variables,
|
env: variables,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.flatMap((text) => parseInfo(text, entry.origin)),
|
Effect.map(parseInfo),
|
||||||
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
||||||
),
|
),
|
||||||
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
||||||
@@ -236,14 +218,14 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
const content = options?.content !== undefined
|
const content = options?.content
|
||||||
? yield* ConfigVariable.substitute({
|
? yield* ConfigVariable.substitute({
|
||||||
type: "virtual",
|
type: "virtual",
|
||||||
source: "OPENCODE_CONFIG_CONTENT",
|
source: "OPENCODE_CONFIG_CONTENT",
|
||||||
dir: location.directory,
|
dir: location.directory,
|
||||||
text: options.content,
|
text: options.content,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
|
Effect.map(parseInfo),
|
||||||
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
|
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
)
|
)
|
||||||
@@ -251,13 +233,13 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
|
|
||||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||||
return [
|
return [
|
||||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
|
||||||
...claude,
|
...claude,
|
||||||
...agents,
|
...agents,
|
||||||
...(supplementary[0] ?? []),
|
...(supplementary[0] ?? []),
|
||||||
...explicit,
|
...explicit,
|
||||||
...direct,
|
...direct,
|
||||||
...supplementary.slice(1).flat(),
|
...supplementary.slice(1).flat(),
|
||||||
|
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||||
...content,
|
...content,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,796 +0,0 @@
|
|||||||
export * as ConfigNormalize from "./normalize"
|
|
||||||
|
|
||||||
import { isDeepStrictEqual } from "node:util"
|
|
||||||
import { Option, Schema } from "effect"
|
|
||||||
import { Info } from "@opencode-ai/schema/config"
|
|
||||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
|
||||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
|
||||||
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
|
|
||||||
import { ConfigFormatter } from "@opencode-ai/schema/config/formatter"
|
|
||||||
import { ConfigLSP } from "@opencode-ai/schema/config/lsp"
|
|
||||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
|
||||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
|
||||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
|
||||||
import { ConfigPolicy } from "@opencode-ai/schema/config/policy"
|
|
||||||
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
|
|
||||||
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
|
||||||
import { ConfigExperimental } from "@opencode-ai/schema/config/experimental"
|
|
||||||
import { Permission } from "@opencode-ai/schema/permission"
|
|
||||||
import { ConfigAgentV1 } from "../v1/config/agent"
|
|
||||||
import { ConfigAttachmentV1 } from "../v1/config/attachment"
|
|
||||||
import { ConfigCommandV1 } from "../v1/config/command"
|
|
||||||
import { ConfigMCPV1 } from "../v1/config/mcp"
|
|
||||||
import { ConfigPermissionV1 } from "../v1/config/permission"
|
|
||||||
import { ConfigPluginV1 } from "../v1/config/plugin"
|
|
||||||
import { ConfigProviderV1 } from "../v1/config/provider"
|
|
||||||
import { ConfigMigrateV1 } from "../v1/config/migrate"
|
|
||||||
import { PositiveInt } from "../schema"
|
|
||||||
|
|
||||||
export interface Diagnostic {
|
|
||||||
readonly kind: "conflict" | "invalid" | "unsupported"
|
|
||||||
readonly path: readonly string[]
|
|
||||||
readonly message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Result =
|
|
||||||
| {
|
|
||||||
readonly type: "normalized"
|
|
||||||
readonly encoded: Readonly<Record<string, unknown>>
|
|
||||||
readonly diagnostics: readonly Diagnostic[]
|
|
||||||
}
|
|
||||||
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
|
||||||
|
|
||||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
|
||||||
const unsupportedTopLevel = ["logLevel", "server", "small_model", "subagent_depth", "layout"] as const
|
|
||||||
const unsupportedExperimental = [
|
|
||||||
"disable_paste_summary",
|
|
||||||
"batch_tool",
|
|
||||||
"openTelemetry",
|
|
||||||
"primary_tools",
|
|
||||||
"continue_loop_on_deny",
|
|
||||||
] as const
|
|
||||||
const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
|
|
||||||
const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
|
|
||||||
|
|
||||||
export function normalize(input: unknown): Result {
|
|
||||||
if (!isRecord(input))
|
|
||||||
return {
|
|
||||||
type: "rejected",
|
|
||||||
diagnostics: [
|
|
||||||
{ kind: "invalid", path: ["$"], message: "rejected configuration because its root is not an object" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const diagnostics: Diagnostic[] = []
|
|
||||||
const encoded: Record<string, unknown> = {}
|
|
||||||
unsupportedTopLevel.forEach((key) => unsupportedIfPresent(input, key, [key], diagnostics))
|
|
||||||
|
|
||||||
const legacySnapshots = own(input, "snapshot")
|
|
||||||
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
|
|
||||||
: undefined
|
|
||||||
const legacyShare = own(input, "autoshare")
|
|
||||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
|
||||||
? "auto"
|
|
||||||
: undefined
|
|
||||||
: undefined
|
|
||||||
const legacyMedia = own(input, "attachment")
|
|
||||||
? decodeValue(ConfigAttachmentV1.Info, input.attachment, ["attachment"], diagnostics)
|
|
||||||
: undefined
|
|
||||||
if (legacyMedia !== undefined) {
|
|
||||||
const migrated = ConfigMigrateV1.migrate({ attachment: legacyMedia }).media
|
|
||||||
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
|
|
||||||
}
|
|
||||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
|
||||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
|
||||||
|
|
||||||
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
|
|
||||||
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
|
|
||||||
mergeMap(
|
|
||||||
encoded,
|
|
||||||
"references",
|
|
||||||
legacyReferences,
|
|
||||||
nativeReferences,
|
|
||||||
isRecord(input.reference) || isRecord(input.references),
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
|
|
||||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
|
|
||||||
diagnoseSelectionMap(input.command, ["command"], diagnostics)
|
|
||||||
const migratedCommands = mapValues(legacyCommands, (value) => {
|
|
||||||
const migrated = ConfigMigrateV1.commands({ value })?.value
|
|
||||||
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
|
|
||||||
})
|
|
||||||
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
|
|
||||||
mergeMap(
|
|
||||||
encoded,
|
|
||||||
"commands",
|
|
||||||
migratedCommands,
|
|
||||||
nativeCommands,
|
|
||||||
isRecord(input.command) || isRecord(input.commands),
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
|
|
||||||
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
|
||||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
|
||||||
)
|
|
||||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
|
||||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
|
||||||
)
|
|
||||||
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
|
|
||||||
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
|
|
||||||
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
|
|
||||||
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
|
|
||||||
mergeMap(
|
|
||||||
encoded,
|
|
||||||
"agents",
|
|
||||||
migratedAgents,
|
|
||||||
nativeAgents,
|
|
||||||
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
|
|
||||||
const legacyProviders = migrateProviders(input.provider, diagnostics)
|
|
||||||
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
|
|
||||||
mergeMap(
|
|
||||||
encoded,
|
|
||||||
"providers",
|
|
||||||
legacyProviders,
|
|
||||||
nativeProviders,
|
|
||||||
isRecord(input.provider) || isRecord(input.providers),
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
|
|
||||||
const toolRules = migrateTools(input.tools, diagnostics)
|
|
||||||
const permissionRules = migratePermissions(input.permission, diagnostics)
|
|
||||||
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
|
|
||||||
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
|
|
||||||
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
|
|
||||||
|
|
||||||
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
|
|
||||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
|
||||||
)
|
|
||||||
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
|
|
||||||
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
|
|
||||||
encoded.plugins = [...legacyPlugins, ...nativePlugins]
|
|
||||||
|
|
||||||
normalizeSkills(input, encoded, diagnostics)
|
|
||||||
normalizeMcp(input, encoded, diagnostics)
|
|
||||||
normalizeCompaction(input, encoded, diagnostics)
|
|
||||||
normalizeExperimental(input, encoded, diagnostics)
|
|
||||||
normalizeWatcher(input, encoded, diagnostics)
|
|
||||||
normalizeFormatter(input, encoded, diagnostics)
|
|
||||||
normalizeLsp(input, encoded, diagnostics)
|
|
||||||
|
|
||||||
const nativeAtomic = {
|
|
||||||
$schema: Info.fields.$schema,
|
|
||||||
shell: Info.fields.shell,
|
|
||||||
model: Info.fields.model,
|
|
||||||
default_agent: Info.fields.default_agent,
|
|
||||||
autoupdate: Info.fields.autoupdate,
|
|
||||||
share: Info.fields.share,
|
|
||||||
enterprise: Info.fields.enterprise,
|
|
||||||
username: Info.fields.username,
|
|
||||||
snapshots: Info.fields.snapshots,
|
|
||||||
media: Info.fields.media,
|
|
||||||
tool_output: Info.fields.tool_output,
|
|
||||||
websearch: Info.fields.websearch,
|
|
||||||
warming: Info.fields.warming,
|
|
||||||
}
|
|
||||||
Object.entries(nativeAtomic).forEach(([key, schema]) => {
|
|
||||||
if (!own(input, key)) return
|
|
||||||
const value = decodeEncoded(schema, input[key], [key], diagnostics)
|
|
||||||
if (value === undefined) return
|
|
||||||
overlay(encoded, key, value, [key], diagnostics)
|
|
||||||
})
|
|
||||||
|
|
||||||
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
|
|
||||||
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
|
|
||||||
|
|
||||||
return { type: "normalized", encoded, diagnostics }
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
|
||||||
if (!own(input, "skills")) return
|
|
||||||
if (Array.isArray(input.skills)) {
|
|
||||||
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isRecord(input.skills)) {
|
|
||||||
invalid(["skills"], diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
encoded.skills = [
|
|
||||||
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
|
|
||||||
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
|
||||||
const legacyServers: Record<string, unknown> = {}
|
|
||||||
const nativeServers: Record<string, unknown> = {}
|
|
||||||
const timeout: Record<string, unknown> = {}
|
|
||||||
if (isRecord(input.experimental) && own(input.experimental, "mcp_timeout")) {
|
|
||||||
const value = decodeEncoded(
|
|
||||||
PositiveInt,
|
|
||||||
input.experimental.mcp_timeout,
|
|
||||||
["experimental", "mcp_timeout"],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
if (value !== undefined) {
|
|
||||||
timeout.catalog = value
|
|
||||||
timeout.execution = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (own(input, "mcp")) {
|
|
||||||
if (!isRecord(input.mcp)) invalid(["mcp"], diagnostics)
|
|
||||||
if (isRecord(input.mcp)) {
|
|
||||||
Object.entries(input.mcp).forEach(([name, value]) => {
|
|
||||||
const path = ["mcp", name]
|
|
||||||
if (isEnabledOnlyMcp(value)) {
|
|
||||||
diagnostics.push({ kind: "unsupported", path, message: "omitted enabled-only legacy MCP entry" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (name === "servers" && !isDirectLegacyMcp(value)) {
|
|
||||||
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
|
|
||||||
setOwn(nativeServers, key, server),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (name === "timeout" && !isDirectLegacyMcp(value)) {
|
|
||||||
normalizeMcpTimeout(value, timeout, path, diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const server = decodeValue(ConfigMCPV1.Info, value, path, diagnostics)
|
|
||||||
if (server !== undefined)
|
|
||||||
setOwn(legacyServers, name, canonical(ConfigMCP.Server, ConfigMigrateV1.migrateMcp(server)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const servers = mergeMaps(legacyServers, nativeServers, ["mcp", "servers"], diagnostics)
|
|
||||||
if (!Object.keys(servers).length && !Object.keys(timeout).length) {
|
|
||||||
if (isRecord(input.mcp) && !Object.keys(input.mcp).length) encoded.mcp = {}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
encoded.mcp = {
|
|
||||||
...(Object.keys(timeout).length ? { timeout } : {}),
|
|
||||||
...(Object.keys(servers).length ? { servers } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMcpTimeout(
|
|
||||||
value: unknown,
|
|
||||||
timeout: Record<string, unknown>,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
|
|
||||||
if (Object.keys(value).length && !recognized.length) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recognized.forEach((key) => {
|
|
||||||
const leaf = decodeEncoded(
|
|
||||||
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
|
|
||||||
value[key],
|
|
||||||
[...path, key],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
if (leaf === undefined) return
|
|
||||||
overlay(timeout, key, leaf, [...path, key], diagnostics)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCompaction(
|
|
||||||
input: Record<string, unknown>,
|
|
||||||
encoded: Record<string, unknown>,
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (!own(input, "compaction")) return
|
|
||||||
if (!isRecord(input.compaction)) {
|
|
||||||
invalid(["compaction"], diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
unsupportedIfPresent(input.compaction, "tail_turns", ["compaction", "tail_turns"], diagnostics)
|
|
||||||
unsupportedIfPresent(input.compaction, "prune", ["compaction", "prune"], diagnostics)
|
|
||||||
const result: Record<string, unknown> = {}
|
|
||||||
if (own(input.compaction, "auto")) {
|
|
||||||
const value = decodeEncoded(
|
|
||||||
ConfigCompaction.Info.fields.auto,
|
|
||||||
input.compaction.auto,
|
|
||||||
["compaction", "auto"],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
if (value !== undefined) result.auto = value
|
|
||||||
}
|
|
||||||
const legacyTokens = own(input.compaction, "preserve_recent_tokens")
|
|
||||||
? decodeEncoded(
|
|
||||||
ConfigCompaction.Keep.fields.tokens,
|
|
||||||
input.compaction.preserve_recent_tokens,
|
|
||||||
["compaction", "preserve_recent_tokens"],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
const nativeKeep = isRecord(input.compaction.keep) ? input.compaction.keep : undefined
|
|
||||||
if (own(input.compaction, "keep") && !nativeKeep) invalid(["compaction", "keep"], diagnostics)
|
|
||||||
const nativeTokens =
|
|
||||||
nativeKeep && own(nativeKeep, "tokens")
|
|
||||||
? decodeEncoded(
|
|
||||||
ConfigCompaction.Keep.fields.tokens,
|
|
||||||
nativeKeep.tokens,
|
|
||||||
["compaction", "keep", "tokens"],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
const tokens = prefer(legacyTokens, nativeTokens, ["compaction", "keep", "tokens"], diagnostics)
|
|
||||||
if (tokens !== undefined) result.keep = { tokens }
|
|
||||||
const legacyBuffer = own(input.compaction, "reserved")
|
|
||||||
? decodeEncoded(
|
|
||||||
ConfigCompaction.Info.fields.buffer,
|
|
||||||
input.compaction.reserved,
|
|
||||||
["compaction", "reserved"],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
const nativeBuffer = own(input.compaction, "buffer")
|
|
||||||
? decodeEncoded(ConfigCompaction.Info.fields.buffer, input.compaction.buffer, ["compaction", "buffer"], diagnostics)
|
|
||||||
: undefined
|
|
||||||
const buffer = prefer(legacyBuffer, nativeBuffer, ["compaction", "buffer"], diagnostics)
|
|
||||||
if (buffer !== undefined) result.buffer = buffer
|
|
||||||
if (Object.keys(result).length || !Object.keys(input.compaction).length) encoded.compaction = result
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeExperimental(
|
|
||||||
input: Record<string, unknown>,
|
|
||||||
encoded: Record<string, unknown>,
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
const result: Record<string, unknown> = {}
|
|
||||||
const generated: unknown[] = []
|
|
||||||
const enabled = decodeProviderList(input, "enabled_providers", diagnostics)
|
|
||||||
if (enabled.present && (!enabled.nonEmpty || enabled.values.length)) {
|
|
||||||
generated.push({ action: "provider.use", resource: "*", effect: "deny" })
|
|
||||||
generated.push(
|
|
||||||
...enabled.values.map((resource) => ({
|
|
||||||
action: "provider.use",
|
|
||||||
resource: ConfigMigrateV1.providerID(resource),
|
|
||||||
effect: "allow",
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const disabled = decodeProviderList(input, "disabled_providers", diagnostics)
|
|
||||||
generated.push(
|
|
||||||
...disabled.values.map((resource) => ({
|
|
||||||
action: "provider.use",
|
|
||||||
resource: ConfigMigrateV1.providerID(resource),
|
|
||||||
effect: "deny",
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
const native: unknown[] = []
|
|
||||||
if (own(input, "experimental")) {
|
|
||||||
if (!isRecord(input.experimental)) invalid(["experimental"], diagnostics)
|
|
||||||
if (isRecord(input.experimental)) {
|
|
||||||
const experimental = input.experimental
|
|
||||||
unsupportedExperimental.forEach((key) =>
|
|
||||||
unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
|
|
||||||
)
|
|
||||||
if (own(experimental, "subagent_depth")) {
|
|
||||||
const value = decodeEncoded(
|
|
||||||
ConfigExperimental.Info.fields.subagent_depth,
|
|
||||||
experimental.subagent_depth,
|
|
||||||
["experimental", "subagent_depth"],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
if (value !== undefined) result.subagent_depth = value
|
|
||||||
}
|
|
||||||
native.push(
|
|
||||||
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (generated.length || native.length || (isRecord(input.experimental) && Array.isArray(input.experimental.policies)))
|
|
||||||
result.policies = [...generated, ...native]
|
|
||||||
if (Object.keys(result).length || (isRecord(input.experimental) && !Object.keys(input.experimental).length))
|
|
||||||
encoded.experimental = result
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
|
||||||
if (!own(input, "watcher")) return
|
|
||||||
if (!isRecord(input.watcher)) {
|
|
||||||
invalid(["watcher"], diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
|
|
||||||
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFormatter(
|
|
||||||
input: Record<string, unknown>,
|
|
||||||
encoded: Record<string, unknown>,
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (!own(input, "formatter")) return
|
|
||||||
if (typeof input.formatter === "boolean") {
|
|
||||||
const value = decodeEncoded(ConfigFormatter.Info, input.formatter, ["formatter"], diagnostics)
|
|
||||||
if (value !== undefined) encoded.formatter = value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
|
|
||||||
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
|
|
||||||
encoded.formatter = entries
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
|
||||||
if (!own(input, "lsp")) return
|
|
||||||
if (typeof input.lsp === "boolean") {
|
|
||||||
const value = decodeEncoded(ConfigLSP.Info, input.lsp, ["lsp"], diagnostics)
|
|
||||||
if (value !== undefined) encoded.lsp = value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
|
|
||||||
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateTools(value: unknown, diagnostics: Diagnostic[]) {
|
|
||||||
if (value === undefined) return []
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
invalid(["tools"], diagnostics)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Object.entries(value).flatMap(([action, raw]) => {
|
|
||||||
const enabled = decodeValue(Schema.Boolean, raw, ["tools", action], diagnostics)
|
|
||||||
if (enabled === undefined) return []
|
|
||||||
return [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect: enabled ? "allow" : "deny" }]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function migratePermissions(value: unknown, diagnostics: Diagnostic[]) {
|
|
||||||
if (value === undefined) return []
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const effect = decodeValue(ConfigPermissionV1.Action, value, ["permission"], diagnostics)
|
|
||||||
return effect === undefined ? [] : [{ action: "*", resource: "*", effect }]
|
|
||||||
}
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
invalid(["permission"], diagnostics)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Object.entries(value).flatMap(([action, raw]) => {
|
|
||||||
if (typeof raw === "string") {
|
|
||||||
const effect = decodeValue(ConfigPermissionV1.Action, raw, ["permission", action], diagnostics)
|
|
||||||
return effect === undefined ? [] : [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect }]
|
|
||||||
}
|
|
||||||
if (!isRecord(raw)) {
|
|
||||||
invalid(["permission", action], diagnostics)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Object.entries(raw).flatMap(([resource, effect], index) => {
|
|
||||||
const decoded = decodeValue(ConfigPermissionV1.Action, effect, ["permission", action, String(index)], diagnostics)
|
|
||||||
return decoded === undefined
|
|
||||||
? []
|
|
||||||
: [{ action: ConfigMigrateV1.normalizeAction(action), resource, effect: decoded }]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateProviders(value: unknown, diagnostics: Diagnostic[]) {
|
|
||||||
if (value === undefined) return {}
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
invalid(["provider"], diagnostics)
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
const candidates = Object.entries(value).flatMap(([name, raw]) => {
|
|
||||||
const path = ["provider", name]
|
|
||||||
diagnoseProviderUnsupported(raw, path, diagnostics)
|
|
||||||
if (invalidProviderOverlays(raw, path, diagnostics)) return []
|
|
||||||
const provider = decodeValue(ConfigProviderV1.Info, raw, path, diagnostics)
|
|
||||||
if (provider === undefined) return []
|
|
||||||
const destination = ConfigMigrateV1.providerID(name)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name,
|
|
||||||
destination,
|
|
||||||
provider: canonical(ConfigProvider.Info, ConfigMigrateV1.migrateProvider(name, provider)),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
const current = new Set(candidates.filter((item) => item.name === item.destination).map((item) => item.destination))
|
|
||||||
const result: Record<string, unknown> = {}
|
|
||||||
candidates.forEach((item) => {
|
|
||||||
if (item.name !== item.destination && current.has(item.destination)) return
|
|
||||||
setOwn(result, item.destination, item.provider)
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidProviderOverlays(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
if (!isRecord(value) || !isRecord(value.options)) return false
|
|
||||||
const headersInvalid =
|
|
||||||
own(value.options, "headers") &&
|
|
||||||
(!isPlainRecord(value.options.headers) ||
|
|
||||||
Object.values(value.options.headers).some((item) => typeof item !== "string"))
|
|
||||||
const bodyInvalid = own(value.options, "body") && !isPlainRecord(value.options.body)
|
|
||||||
if (headersInvalid) invalid([...path, "options", "headers"], diagnostics)
|
|
||||||
if (bodyInvalid) invalid([...path, "options", "body"], diagnostics)
|
|
||||||
return headersInvalid || bodyInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnoseProviderUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
if (!isRecord(value)) return
|
|
||||||
unsupportedProvider.forEach((key) => unsupportedIfPresent(value, key, [...path, key], diagnostics))
|
|
||||||
if (!isRecord(value.models)) return
|
|
||||||
Object.entries(value.models).forEach(([name, model]) => {
|
|
||||||
if (!isRecord(model)) return
|
|
||||||
unsupportedModel.forEach((key) => unsupportedIfPresent(model, key, [...path, "models", name, key], diagnostics))
|
|
||||||
if (own(model, "status") && model.status !== "deprecated")
|
|
||||||
unsupportedIfPresent(model, "status", [...path, "models", name, "status"], diagnostics)
|
|
||||||
if (own(model, "interleaved") && typeof model.interleaved === "boolean")
|
|
||||||
unsupportedIfPresent(model, "interleaved", [...path, "models", name, "interleaved"], diagnostics)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnoseAgentUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
if (!isRecord(value)) return
|
|
||||||
Object.entries(value).forEach(([name, agent]) => {
|
|
||||||
if (!isRecord(agent)) return
|
|
||||||
unsupportedIfPresent(agent, "name", [...path, name, "name"], diagnostics)
|
|
||||||
diagnoseSelection(agent, [...path, name], diagnostics)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnoseSelectionMap(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
if (!isRecord(value)) return
|
|
||||||
Object.entries(value).forEach(([name, entry]) => {
|
|
||||||
if (isRecord(entry)) diagnoseSelection(entry, [...path, name], diagnostics)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnoseSelection(value: Record<string, unknown>, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
const modelValid = typeof value.model === "string" && /^[^/#]+\/[^#]+$/.test(value.model)
|
|
||||||
if (own(value, "model") && typeof value.model === "string" && !modelValid)
|
|
||||||
diagnostics.push({
|
|
||||||
kind: "unsupported",
|
|
||||||
path: [...path, "model"],
|
|
||||||
message: "omitted unsupported legacy model reference",
|
|
||||||
})
|
|
||||||
if (
|
|
||||||
own(value, "variant") &&
|
|
||||||
typeof value.variant === "string" &&
|
|
||||||
(!modelValid || value.variant.length === 0 || value.variant.includes("#"))
|
|
||||||
)
|
|
||||||
diagnostics.push({
|
|
||||||
kind: "unsupported",
|
|
||||||
path: [...path, "variant"],
|
|
||||||
message: "omitted unsupported legacy model variant",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeProviderList(
|
|
||||||
input: Record<string, unknown>,
|
|
||||||
key: "enabled_providers" | "disabled_providers",
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (!own(input, key)) return { present: false, nonEmpty: false, values: [] as string[] }
|
|
||||||
if (!Array.isArray(input[key])) {
|
|
||||||
invalid([key], diagnostics)
|
|
||||||
return { present: true, nonEmpty: true, values: [] as string[] }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
present: true,
|
|
||||||
nonEmpty: input[key].length > 0,
|
|
||||||
values: decodeList(input[key], Schema.String, [key], diagnostics),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
||||||
value: unknown,
|
|
||||||
schema: S,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (value === undefined) return {}
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value).flatMap(([name, raw]) => {
|
|
||||||
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
|
|
||||||
return decoded === undefined ? [] : [[name, decoded]]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
||||||
value: unknown,
|
|
||||||
schema: S,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (value === undefined) return {} as Record<string, S["Type"]>
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return {} as Record<string, S["Type"]>
|
|
||||||
}
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value).flatMap(([name, raw]) => {
|
|
||||||
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
|
|
||||||
return decoded === undefined ? [] : [[name, decoded]]
|
|
||||||
}),
|
|
||||||
) as Record<string, S["Type"]>
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
||||||
value: unknown,
|
|
||||||
schema: S,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (value === undefined) return [] as S["Encoded"][]
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return [] as S["Encoded"][]
|
|
||||||
}
|
|
||||||
return value.flatMap((item, index) => {
|
|
||||||
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
|
|
||||||
return decoded === undefined ? [] : [decoded]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
||||||
value: unknown,
|
|
||||||
schema: S,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (value === undefined) return [] as S["Type"][]
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return [] as S["Type"][]
|
|
||||||
}
|
|
||||||
return value.flatMap((item, index) => {
|
|
||||||
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
|
|
||||||
return decoded === undefined ? [] : [decoded]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeValue<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
||||||
schema: S,
|
|
||||||
value: unknown,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
const decoded = Schema.decodeUnknownOption(schema, options)(value)
|
|
||||||
if (Option.isSome(decoded)) return decoded.value
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeEncoded<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
||||||
schema: S,
|
|
||||||
value: unknown,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
const decoded = Schema.decodeUnknownOption(schema, options)(value)
|
|
||||||
if (Option.isNone(decoded)) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const encoded = Schema.encodeUnknownOption(schema, options)(decoded.value)
|
|
||||||
if (Option.isSome(encoded)) return plain(encoded.value)
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function canonical<S extends Schema.Codec<unknown, unknown, never, never>>(schema: S, value: unknown) {
|
|
||||||
return plain(
|
|
||||||
Option.getOrThrow(
|
|
||||||
Schema.decodeUnknownOption(
|
|
||||||
schema,
|
|
||||||
options,
|
|
||||||
)(plain(value)).pipe(Option.flatMap((decoded) => Schema.encodeUnknownOption(schema, options)(decoded))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function plain(value: unknown): unknown {
|
|
||||||
if (Array.isArray(value)) return value.map(plain)
|
|
||||||
if (!isRecord(value)) return value
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, plain(item)]])),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeMap(
|
|
||||||
target: Record<string, unknown>,
|
|
||||||
key: string,
|
|
||||||
legacy: Readonly<Record<string, unknown>>,
|
|
||||||
native: Readonly<Record<string, unknown>>,
|
|
||||||
present: boolean,
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
const merged = mergeMaps(legacy, native, [key], diagnostics)
|
|
||||||
if (present) target[key] = merged
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeMaps(
|
|
||||||
legacy: Readonly<Record<string, unknown>>,
|
|
||||||
native: Readonly<Record<string, unknown>>,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
const result = Object.fromEntries(Object.entries(legacy))
|
|
||||||
Object.entries(native).forEach(([name, value]) => {
|
|
||||||
if (own(result, name) && !isDeepStrictEqual(result[name], value)) conflict([...path, name], diagnostics)
|
|
||||||
setOwn(result, name, value)
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapValues<A>(input: Readonly<Record<string, A>>, map: (value: A) => unknown) {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(input).flatMap(([key, value]) => {
|
|
||||||
const mapped = map(value)
|
|
||||||
return mapped === undefined ? [] : [[key, mapped]]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function overlay(
|
|
||||||
target: Record<string, unknown>,
|
|
||||||
key: string,
|
|
||||||
value: unknown,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (own(target, key) && !isDeepStrictEqual(target[key], value)) conflict(path, diagnostics)
|
|
||||||
target[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
function prefer(legacy: unknown, native: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
if (native === undefined) return legacy
|
|
||||||
if (legacy !== undefined && !isDeepStrictEqual(legacy, native)) conflict(path, diagnostics)
|
|
||||||
return native
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsupportedIfPresent(value: Record<string, unknown>, key: string, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
if (!own(value, key)) return
|
|
||||||
diagnostics.push({ kind: "unsupported", path, message: "omitted unsupported legacy setting" })
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalid(path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
diagnostics.push({ kind: "invalid", path, message: "skipped malformed recognized value" })
|
|
||||||
}
|
|
||||||
|
|
||||||
function conflict(path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
diagnostics.push({ kind: "conflict", path, message: "retained native value over legacy value" })
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDirectLegacyMcp(value: unknown) {
|
|
||||||
return isRecord(value) && (value.type === "local" || value.type === "remote")
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEnabledOnlyMcp(value: unknown) {
|
|
||||||
return isRecord(value) && !own(value, "type") && typeof value.enabled === "boolean"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
if (!isRecord(value)) return false
|
|
||||||
const prototype = Object.getPrototypeOf(value)
|
|
||||||
return prototype === Object.prototype || prototype === null
|
|
||||||
}
|
|
||||||
|
|
||||||
function own(value: Record<string, unknown>, key: string) {
|
|
||||||
return Object.prototype.hasOwnProperty.call(value, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
|
|
||||||
Object.defineProperty(value, key, { value: item, enumerable: true, configurable: true, writable: true })
|
|
||||||
}
|
|
||||||
@@ -161,14 +161,11 @@ function isPathAction(action: string): action is PathAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function expandHome(resource: string, home: string) {
|
function expandHome(resource: string, home: string) {
|
||||||
|
if (resource.startsWith("~/")) return home + resource.slice(1)
|
||||||
if (resource === "~") return home
|
if (resource === "~") return home
|
||||||
if (resource === "$HOME") return home
|
if (resource === "$HOME") return home
|
||||||
const relative = resource.startsWith("~/")
|
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
|
||||||
? resource.slice(2)
|
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
|
||||||
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
|
|
||||||
? resource.slice(6)
|
|
||||||
: undefined
|
|
||||||
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
|
|
||||||
return resource
|
return resource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export const layer = Layer.effect(
|
|||||||
id,
|
id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
|
...(input.coalesce === undefined ? {} : { coalesce: input.coalesce }),
|
||||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||||
fields: input.fields,
|
fields: input.fields,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,12 +340,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
|||||||
hook: (name, callback) => hooks.register("session", name, callback),
|
hook: (name, callback) => hooks.register("session", name, callback),
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
runtime.session.create({
|
runtime.session.create({
|
||||||
id: input.id,
|
id: input?.id,
|
||||||
title: input.title,
|
title: input?.title,
|
||||||
agent: input.agent,
|
agent: input?.agent,
|
||||||
model: input.model,
|
model: input?.model,
|
||||||
location:
|
location:
|
||||||
input.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||||
}),
|
}),
|
||||||
get: (input) => runtime.session.get(input.sessionID),
|
get: (input) => runtime.session.get(input.sessionID),
|
||||||
prompt: runtime.session.prompt,
|
prompt: runtime.session.prompt,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * as PluginPromise from "./promise"
|
|||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
||||||
|
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
|
||||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Integration } from "@opencode-ai/schema/integration"
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
@@ -57,6 +58,62 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function sessionHook<Name extends keyof SessionHooks>(
|
||||||
|
name: Name,
|
||||||
|
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||||
|
): Promise<Registration>
|
||||||
|
function sessionHook(
|
||||||
|
...registration: {
|
||||||
|
[Name in keyof SessionHooks]: [
|
||||||
|
name: Name,
|
||||||
|
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||||
|
]
|
||||||
|
}[keyof SessionHooks]
|
||||||
|
) {
|
||||||
|
if (registration[0] !== "http")
|
||||||
|
return register(
|
||||||
|
host.session.hook(registration[0], (event) =>
|
||||||
|
Effect.promise(() => Promise.resolve(registration[1](event))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return register(
|
||||||
|
host.session.hook("http", (event) => {
|
||||||
|
const middlewares: SessionHttpMiddleware[] = []
|
||||||
|
const output: SessionHttp = {
|
||||||
|
...event,
|
||||||
|
use: (item) => {
|
||||||
|
middlewares.push(item)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
|
||||||
|
Effect.flatMap(() =>
|
||||||
|
Effect.forEach(
|
||||||
|
middlewares,
|
||||||
|
(item) =>
|
||||||
|
event.use((input, next) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: (signal) => {
|
||||||
|
const inputSignal = AbortSignal.any([signal, input.signal])
|
||||||
|
return Promise.resolve(
|
||||||
|
item(new Request(input, { signal: inputSignal }), (request) => {
|
||||||
|
const requestSignal = AbortSignal.any([signal, request.signal])
|
||||||
|
return Effect.runPromiseWith(
|
||||||
|
context,
|
||||||
|
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ discard: true },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const context2: Context = {
|
const context2: Context = {
|
||||||
app: host.app,
|
app: host.app,
|
||||||
options: host.options,
|
options: host.options,
|
||||||
@@ -265,25 +322,28 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
hook: (name, callback) =>
|
hook: sessionHook,
|
||||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
run(
|
run(
|
||||||
host.session.create({
|
host.session.create(
|
||||||
id: input.id == null ? undefined : Session.ID.make(input.id),
|
input === undefined
|
||||||
agent: Agent.ID.make(input.agent),
|
? undefined
|
||||||
model: model(input.model),
|
: {
|
||||||
location:
|
id: input.id == null ? undefined : Session.ID.make(input.id),
|
||||||
input.location == null
|
agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
|
||||||
? undefined
|
model: input.model == null ? undefined : model(input.model),
|
||||||
: Location.Ref.make({
|
location:
|
||||||
directory: AbsolutePath.make(input.location.directory),
|
input.location == null
|
||||||
workspaceID:
|
? undefined
|
||||||
input.location.workspaceID === undefined
|
: Location.Ref.make({
|
||||||
? undefined
|
directory: AbsolutePath.make(input.location.directory),
|
||||||
: Workspace.ID.make(input.location.workspaceID),
|
workspaceID:
|
||||||
}),
|
input.location.workspaceID === undefined
|
||||||
}),
|
? undefined
|
||||||
|
: Workspace.ID.make(input.location.workspaceID),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })),
|
get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })),
|
||||||
prompt: (input) =>
|
prompt: (input) =>
|
||||||
|
|||||||
@@ -221,18 +221,18 @@ export const OpenAIPlugin = define({
|
|||||||
}
|
}
|
||||||
draft.cost = []
|
draft.cost = []
|
||||||
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
||||||
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
|
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
yield* ctx.session.hook("http.request", (evt) =>
|
yield* ctx.session.hook("http", (evt) =>
|
||||||
Effect.sync(() => {
|
evt.use((request, next) => {
|
||||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
|
||||||
const url = new URL(evt.request.url)
|
const url = new URL(request.url)
|
||||||
evt.request.headers.set("originator", "opencode")
|
request.headers.set("originator", "opencode")
|
||||||
evt.request.headers.set("session-id", evt.sessionID)
|
request.headers.set("session-id", evt.sessionID)
|
||||||
if (url.origin !== "https://api.openai.com") return
|
if (url.origin !== "https://api.openai.com") return next(request)
|
||||||
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { Info } from "../model"
|
|||||||
import { SessionUsage } from "./usage"
|
import { SessionUsage } from "./usage"
|
||||||
|
|
||||||
const DEFAULT_BUFFER = 20_000
|
const DEFAULT_BUFFER = 20_000
|
||||||
const DEFAULT_KEEP_TOKENS = 15_000
|
const DEFAULT_KEEP_TOKENS = 8_000
|
||||||
const OUTPUT_TOKEN_MAX = 32_000
|
const OUTPUT_TOKEN_MAX = 32_000
|
||||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * as SessionModelRequest from "./model-request"
|
|||||||
|
|
||||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||||
|
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
|
||||||
import type { Content } from "@opencode-ai/schema/tool"
|
import type { Content } from "@opencode-ai/schema/tool"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||||
@@ -229,31 +230,44 @@ export const layer = Layer.effect(
|
|||||||
const options: StreamOptions = {
|
const options: StreamOptions = {
|
||||||
http: (request, handler) =>
|
http: (request, handler) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const before = yield* hooks.trigger("session", "http.request", {
|
let latest = request
|
||||||
|
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
||||||
|
const middlewares: SessionHttpMiddleware[] = []
|
||||||
|
const web = yield* HttpClientRequest.toWeb(request)
|
||||||
|
yield* hooks.trigger("session", "http", {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: resolved.ref,
|
model: resolved.ref,
|
||||||
request: yield* HttpClientRequest.toWeb(request),
|
use: (item) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
middlewares.push(item)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
let sent = HttpClientRequest.fromWeb(before.request)
|
const send = (input: Request) =>
|
||||||
if (before.request.body)
|
Effect.gen(function* () {
|
||||||
sent = HttpClientRequest.bodyUint8Array(
|
let sent = HttpClientRequest.fromWeb(input)
|
||||||
sent,
|
if (input.body)
|
||||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
sent = HttpClientRequest.bodyUint8Array(
|
||||||
before.request.headers.get("content-type") ?? undefined,
|
sent,
|
||||||
)
|
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
|
||||||
const response = yield* handler(sent)
|
input.headers.get("content-type") ?? undefined,
|
||||||
const after = yield* hooks.trigger("session", "http.response", {
|
)
|
||||||
sessionID: session.id,
|
latest = sent
|
||||||
agent: agent.id,
|
const response = yield* handler(sent)
|
||||||
model: resolved.ref,
|
const body = [204, 205, 304].includes(response.status)
|
||||||
request: before.request,
|
? null
|
||||||
response: new Response(
|
: yield* Stream.toReadableStreamEffect(response.stream)
|
||||||
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
|
const output = new Response(body, { status: response.status, headers: response.headers })
|
||||||
{ status: response.status, headers: response.headers },
|
origins.set(output, sent)
|
||||||
),
|
return output
|
||||||
})
|
})
|
||||||
return HttpClientResponse.fromWeb(sent, after.response)
|
const dispatch = middlewares.reduce<SessionHttpHandler>(
|
||||||
|
(next, item) => (input: Request) => item(input, next),
|
||||||
|
send,
|
||||||
|
)
|
||||||
|
const response = yield* dispatch(web)
|
||||||
|
const origin = origins.get(response) ?? latest
|
||||||
|
return HttpClientResponse.fromWeb(origin, response)
|
||||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||||
}
|
}
|
||||||
if (promptCacheSnapshots) {
|
if (promptCacheSnapshots) {
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
export * as SessionTransfer from "./transfer"
|
|
||||||
|
|
||||||
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
|
||||||
import { Tool } from "@opencode-ai/schema/tool"
|
|
||||||
import { eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
|
||||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
|
||||||
import path from "path"
|
|
||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
|
||||||
import { App } from "../app"
|
|
||||||
import { Bus } from "../bus"
|
|
||||||
import { Database } from "../database/database"
|
|
||||||
import { Location } from "../location"
|
|
||||||
import { Project } from "../project"
|
|
||||||
import { ProjectTable } from "../project/sql"
|
|
||||||
import { AbsolutePath, RelativePath } from "../schema"
|
|
||||||
import { Session } from "../session"
|
|
||||||
import { Slug } from "../util/slug"
|
|
||||||
import { SessionEvent } from "./event"
|
|
||||||
import { SessionMessage } from "./message"
|
|
||||||
import { SessionProjector } from "./projector"
|
|
||||||
import { SessionMessageTable, SessionTable } from "./sql"
|
|
||||||
|
|
||||||
export const Data = SessionTransfer.Data
|
|
||||||
export type Data = SessionTransfer.Data
|
|
||||||
|
|
||||||
export class ImportConflictError extends Schema.TaggedErrorClass<ImportConflictError>()(
|
|
||||||
"SessionTransfer.ImportConflictError",
|
|
||||||
{ sessionID: Session.ID },
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly export: (input: {
|
|
||||||
sessionID: Session.ID
|
|
||||||
sanitize?: boolean
|
|
||||||
}) => Effect.Effect<Data, Session.NotFoundError | Session.MessageDecodeError>
|
|
||||||
readonly import: (input: {
|
|
||||||
data: Data
|
|
||||||
location: Location.Ref
|
|
||||||
}) => Effect.Effect<Session.Info, ImportConflictError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTransfer") {}
|
|
||||||
|
|
||||||
const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const app = yield* App.Metadata
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
const projects = yield* Project.Service
|
|
||||||
const sessions = yield* Session.Service
|
|
||||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
|
||||||
|
|
||||||
const persistProject = (project: Project.Resolved) => {
|
|
||||||
const vcs = project.vcs?.type
|
|
||||||
return db
|
|
||||||
.insert(ProjectTable)
|
|
||||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: ProjectTable.id,
|
|
||||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
|
||||||
setWhere: or(
|
|
||||||
ne(ProjectTable.worktree, project.canonical),
|
|
||||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
export: Effect.fn("SessionTransfer.export")(function* (input) {
|
|
||||||
const data = {
|
|
||||||
info: yield* sessions.get(input.sessionID),
|
|
||||||
messages: yield* sessions.messages({ sessionID: input.sessionID, order: "asc" }),
|
|
||||||
}
|
|
||||||
return input.sanitize ? sanitize(data) : data
|
|
||||||
}),
|
|
||||||
import: Effect.fn("SessionTransfer.import")(function* (input) {
|
|
||||||
const sessionID = input.data.info.id
|
|
||||||
const recorded = yield* db
|
|
||||||
.select({ id: SessionTable.id })
|
|
||||||
.from(SessionTable)
|
|
||||||
.where(eq(SessionTable.id, sessionID))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (recorded) return yield* new ImportConflictError({ sessionID })
|
|
||||||
const project = yield* projects.resolve(input.location.directory)
|
|
||||||
yield* persistProject(project)
|
|
||||||
const messages = input.data.messages.map((message, index) => {
|
|
||||||
const encoded = encodeMessage(message)
|
|
||||||
const { id: _, type, ...data } = encoded
|
|
||||||
return {
|
|
||||||
id: message.id,
|
|
||||||
session_id: sessionID,
|
|
||||||
type,
|
|
||||||
seq: index + 1,
|
|
||||||
time_created: DateTime.toEpochMillis(message.time.created),
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
yield* bus
|
|
||||||
.publish(
|
|
||||||
SessionEvent.Created,
|
|
||||||
{
|
|
||||||
sessionID,
|
|
||||||
slug: Slug.create(),
|
|
||||||
version: app.version,
|
|
||||||
projectID: project.id,
|
|
||||||
location: input.location,
|
|
||||||
subpath: RelativePath.make(path.relative(project.directory, input.location.directory).replaceAll("\\", "/")),
|
|
||||||
title: input.data.info.title,
|
|
||||||
agent: input.data.info.agent,
|
|
||||||
model: input.data.info.model,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: input.location,
|
|
||||||
commit: (seq) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (messages.length > 0) {
|
|
||||||
yield* db.insert(SessionMessageTable).values(messages).run().pipe(Effect.orDie)
|
|
||||||
yield* Bus.reserveSequence(db, sessionID, seq + messages.length)
|
|
||||||
}
|
|
||||||
yield* db
|
|
||||||
.update(SessionTable)
|
|
||||||
.set({
|
|
||||||
cost: input.data.info.cost,
|
|
||||||
tokens_input: input.data.info.tokens.input,
|
|
||||||
tokens_output: input.data.info.tokens.output,
|
|
||||||
tokens_reasoning: input.data.info.tokens.reasoning,
|
|
||||||
tokens_cache_read: input.data.info.tokens.cache.read,
|
|
||||||
tokens_cache_write: input.data.info.tokens.cache.write,
|
|
||||||
time_created: DateTime.toEpochMillis(input.data.info.time.created),
|
|
||||||
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
|
|
||||||
time_archived: input.data.info.time.archived
|
|
||||||
? DateTime.toEpochMillis(input.data.info.time.archived)
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
.where(eq(SessionTable.id, sessionID))
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
Effect.catchDefect((defect) =>
|
|
||||||
defect instanceof SessionProjector.SessionAlreadyProjected
|
|
||||||
? Effect.fail(new ImportConflictError({ sessionID }))
|
|
||||||
: Effect.die(defect),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return yield* sessions.get(sessionID).pipe(Effect.orDie)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const node = makeGlobalNode({
|
|
||||||
service: Service,
|
|
||||||
layer,
|
|
||||||
deps: [App.node, Bus.node, Database.node, Project.node, Session.node],
|
|
||||||
})
|
|
||||||
|
|
||||||
function redact(kind: string, id: string, value: string) {
|
|
||||||
return value.trim() ? `[redacted:${kind}:${id}]` : value
|
|
||||||
}
|
|
||||||
|
|
||||||
function metadata(kind: string, id: string, value: Readonly<Record<string, unknown>> | undefined) {
|
|
||||||
if (!value) return value
|
|
||||||
return Object.keys(value).length > 0 ? { redacted: `${kind}:${id}` } : value
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitize(data: Data): Data {
|
|
||||||
return {
|
|
||||||
info: {
|
|
||||||
...data.info,
|
|
||||||
title: data.info.title === undefined ? undefined : redact("session-title", data.info.id, data.info.title),
|
|
||||||
location: {
|
|
||||||
...data.info.location,
|
|
||||||
directory: AbsolutePath.make(`/${redact("session-directory", data.info.id, data.info.location.directory)}`),
|
|
||||||
},
|
|
||||||
revert: data.info.revert
|
|
||||||
? {
|
|
||||||
...data.info.revert,
|
|
||||||
files: data.info.revert.files?.map((file, index) => ({
|
|
||||||
...file,
|
|
||||||
file: redact("revert-file", String(index), file.file),
|
|
||||||
patch: redact("revert-patch", String(index), file.patch),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
messages: data.messages.map(sanitizeMessage),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
|
|
||||||
const meta = metadata("message-metadata", message.id, message.metadata)
|
|
||||||
if (message.type === "user")
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
metadata: meta,
|
|
||||||
text: redact("text", message.id, message.text),
|
|
||||||
files: message.files?.map((file, index) => ({
|
|
||||||
...file,
|
|
||||||
data: "",
|
|
||||||
source: { type: "inline" },
|
|
||||||
name: file.name === undefined ? undefined : redact("file-name", String(index), file.name),
|
|
||||||
description:
|
|
||||||
file.description === undefined ? undefined : redact("file-description", String(index), file.description),
|
|
||||||
mention: file.mention
|
|
||||||
? { ...file.mention, text: redact("file-mention", String(index), file.mention.text) }
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
agents: message.agents?.map((agent, index) => ({
|
|
||||||
...agent,
|
|
||||||
name: redact("agent-name", String(index), agent.name),
|
|
||||||
mention: agent.mention
|
|
||||||
? { ...agent.mention, text: redact("agent-mention", String(index), agent.mention.text) }
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
if (message.type === "synthetic")
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
metadata: meta,
|
|
||||||
text: redact("synthetic", message.id, message.text),
|
|
||||||
description:
|
|
||||||
message.description === undefined
|
|
||||||
? undefined
|
|
||||||
: redact("synthetic-description", message.id, message.description),
|
|
||||||
}
|
|
||||||
if (message.type === "system")
|
|
||||||
return { ...message, metadata: meta, text: redact("system", message.id, message.text) }
|
|
||||||
if (message.type === "skill") return { ...message, metadata: meta, text: redact("skill", message.id, message.text) }
|
|
||||||
if (message.type === "shell")
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
metadata: meta,
|
|
||||||
command: redact("shell-command", message.id, message.command),
|
|
||||||
output: message.output
|
|
||||||
? { ...message.output, output: redact("shell-output", message.id, message.output.output) }
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
if (message.type === "assistant")
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
metadata: meta,
|
|
||||||
content: message.content.map((content) => {
|
|
||||||
if (content.type === "text")
|
|
||||||
return {
|
|
||||||
...content,
|
|
||||||
text: redact("text", message.id, content.text),
|
|
||||||
state: content.state ? { redacted: `text-state:${message.id}` } : undefined,
|
|
||||||
}
|
|
||||||
if (content.type === "reasoning")
|
|
||||||
return {
|
|
||||||
...content,
|
|
||||||
text: redact("reasoning", message.id, content.text),
|
|
||||||
state: content.state ? { redacted: `reasoning-state:${message.id}` } : undefined,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...content,
|
|
||||||
providerState: content.providerState ? { redacted: `tool-provider-state:${message.id}` } : undefined,
|
|
||||||
providerResultState: content.providerResultState
|
|
||||||
? { redacted: `tool-provider-result-state:${message.id}` }
|
|
||||||
: undefined,
|
|
||||||
state: sanitizeToolState(message.id, content.state),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
if (message.type === "compaction") {
|
|
||||||
if (message.status === "failed")
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
metadata: meta,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
metadata: meta,
|
|
||||||
summary: redact("compaction-summary", message.id, message.summary),
|
|
||||||
recent: redact("compaction-recent", message.id, message.recent),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ...message, metadata: meta }
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeToolState(id: string, state: SessionMessage.ToolState): SessionMessage.ToolState {
|
|
||||||
if (state.status === "streaming") return { ...state, input: redact("tool-input", id, state.input) }
|
|
||||||
if (state.status === "running")
|
|
||||||
return { ...state, input: { redacted: `tool-input:${id}` }, metadata: { redacted: `tool-metadata:${id}` } }
|
|
||||||
const meta = state.metadata === undefined ? undefined : { redacted: `tool-metadata:${id}` }
|
|
||||||
if (state.status === "completed")
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
input: { redacted: `tool-input:${id}` },
|
|
||||||
content: [
|
|
||||||
sanitizeToolContent(id, state.content[0]),
|
|
||||||
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
|
|
||||||
],
|
|
||||||
metadata: meta,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
input: { redacted: `tool-input:${id}` },
|
|
||||||
content: state.content
|
|
||||||
? [
|
|
||||||
sanitizeToolContent(id, state.content[0]),
|
|
||||||
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
|
|
||||||
]
|
|
||||||
: undefined,
|
|
||||||
metadata: meta,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeToolContent(id: string, content: Tool.Content): Tool.Content {
|
|
||||||
if (content.type === "text") return { ...content, text: redact("tool-output", id, content.text) }
|
|
||||||
return {
|
|
||||||
...content,
|
|
||||||
uri: redact("tool-file-uri", id, content.uri),
|
|
||||||
name: content.name === undefined ? undefined : redact("tool-file-name", id, content.name),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ export * as WebSearchTool from "./websearch"
|
|||||||
|
|
||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { Effect, Schema, Semaphore } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Form } from "../../form"
|
import { Form } from "../../form"
|
||||||
import { KV } from "../../kv"
|
import { KV } from "../../kv"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
@@ -10,7 +10,6 @@ import { WebSearch } from "../../websearch"
|
|||||||
|
|
||||||
export const name = "websearch"
|
export const name = "websearch"
|
||||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||||
const providerSelectionLock = Semaphore.makeUnsafe(1)
|
|
||||||
|
|
||||||
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
||||||
|
|
||||||
@@ -30,7 +29,6 @@ export const Plugin = {
|
|||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const forms = yield* Form.Service
|
const forms = yield* Form.Service
|
||||||
const kv = yield* KV.Service
|
const kv = yield* KV.Service
|
||||||
const websearch = yield* WebSearch.Service
|
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
@@ -51,90 +49,72 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||||
})
|
})
|
||||||
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
|
const result = yield* ctx.websearch.query(input).pipe(
|
||||||
ctx.websearch.query(input).pipe(
|
Effect.catch((error) => {
|
||||||
Effect.catch((error) => {
|
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
return Effect.gen(function* () {
|
||||||
return providerSelectionLock
|
const providers = (yield* ctx.websearch.providers()).data
|
||||||
.withPermit(
|
const defaultProvider = providers[0]
|
||||||
Effect.gen(function* () {
|
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||||
if (yield* websearch.default()) return yield* Effect.void
|
const response = yield* forms.ask({
|
||||||
const providers = (yield* ctx.websearch.providers()).data
|
sessionID: context.sessionID,
|
||||||
const defaultProvider = providers[0]
|
title: "Web Search",
|
||||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
coalesce: `${context.messageID}:websearch-consent`,
|
||||||
const response = yield* forms.ask({
|
metadata: { kind: "websearch.provider" },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "choice",
|
||||||
|
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
custom: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "allow",
|
||||||
|
label: `Allow web search via ${defaultProvider.name}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "choose",
|
||||||
|
label: "Choose another provider",
|
||||||
|
},
|
||||||
|
{ value: "disable", label: "Disable web search" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||||
|
if (response.answer.choice === "disable") {
|
||||||
|
yield* kv.set("websearch:provider", false)
|
||||||
|
return yield* new WebSearch.DisabledError()
|
||||||
|
}
|
||||||
|
const selection =
|
||||||
|
response.answer.choice === "choose"
|
||||||
|
? yield* forms.ask({
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
title: "Web Search",
|
title: "Choose a web search provider",
|
||||||
|
coalesce: `${context.messageID}:websearch-provider`,
|
||||||
metadata: { kind: "websearch.provider" },
|
metadata: { kind: "websearch.provider" },
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
key: "choice",
|
key: "provider",
|
||||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
description: "Choose a provider for web search.",
|
||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
required: true,
|
||||||
custom: false,
|
custom: false,
|
||||||
options: [
|
options: providers.map((provider) => ({ value: provider.id, label: provider.name })),
|
||||||
{
|
|
||||||
value: "allow",
|
|
||||||
label: `Allow web search via ${defaultProvider.name}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "choose",
|
|
||||||
label: "Choose another provider",
|
|
||||||
},
|
|
||||||
{ value: "disable", label: "Disable web search" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
if (response.status === "cancelled")
|
: undefined
|
||||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||||
if (response.answer.choice === "disable") {
|
const providerID = selection?.answer.provider ?? defaultProvider.id
|
||||||
yield* kv.set("websearch:provider", false)
|
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
|
||||||
return yield* new WebSearch.DisabledError()
|
return yield* new WebSearch.ProviderRequiredError()
|
||||||
}
|
yield* kv.set("websearch:provider", providerID)
|
||||||
const selection =
|
return yield* ctx.websearch.query(input)
|
||||||
response.answer.choice === "choose"
|
})
|
||||||
? yield* forms.ask({
|
}),
|
||||||
sessionID: context.sessionID,
|
)
|
||||||
title: "Choose a web search provider",
|
|
||||||
metadata: { kind: "websearch.provider" },
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
key: "provider",
|
|
||||||
description: "Choose a provider for web search.",
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
custom: false,
|
|
||||||
options: providers.map((provider) => ({
|
|
||||||
value: provider.id,
|
|
||||||
label: provider.name,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
if (selection?.status === "cancelled")
|
|
||||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
|
||||||
const providerID = selection?.answer.provider ?? defaultProvider.id
|
|
||||||
if (
|
|
||||||
typeof providerID !== "string" ||
|
|
||||||
!providers.some((provider) => provider.id === providerID)
|
|
||||||
)
|
|
||||||
return yield* new WebSearch.ProviderRequiredError()
|
|
||||||
return yield* kv.set("websearch:provider", providerID)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
Effect.timeoutOrElse({
|
|
||||||
duration: "1 minute",
|
|
||||||
orElse: () => Effect.fail(new Error("Web search cancelled")),
|
|
||||||
}),
|
|
||||||
Effect.andThen(Effect.suspend(search)),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const result = yield* search()
|
|
||||||
const output = {
|
const output = {
|
||||||
provider: result.data.providerID,
|
provider: result.data.providerID,
|
||||||
results: result.data.results,
|
results: result.data.results,
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
export * as ConfigMigrateV1 from "./migrate"
|
export * as ConfigMigrateV1 from "./migrate"
|
||||||
|
|
||||||
import { Info } from "@opencode-ai/schema/config"
|
|
||||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
|
||||||
import { Schema } from "effect"
|
|
||||||
import { ConfigV1 } from "./config"
|
import { ConfigV1 } from "./config"
|
||||||
import { ConfigAgentV1 } from "./agent"
|
import { ConfigAgentV1 } from "./agent"
|
||||||
import { ConfigCommandV1 } from "./command"
|
import { ConfigCommandV1 } from "./command"
|
||||||
@@ -13,54 +10,82 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
|
|||||||
import { Provider } from "../../provider"
|
import { Provider } from "../../provider"
|
||||||
import { Model } from "../../model"
|
import { Model } from "../../model"
|
||||||
|
|
||||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
const keys = new Set([
|
||||||
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
|
"logLevel",
|
||||||
const encodeInfo = Schema.encodeSync(Info)
|
"server",
|
||||||
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
|
"command",
|
||||||
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
|
"reference",
|
||||||
export function migrate(info: typeof ConfigV1.Info.Type) {
|
"snapshot",
|
||||||
return encodeInfo(
|
"plugin",
|
||||||
decodeInfo(
|
"autoshare",
|
||||||
JSON.stringify({
|
"disabled_providers",
|
||||||
$schema: info.$schema,
|
"enabled_providers",
|
||||||
shell: info.shell,
|
"small_model",
|
||||||
model: modelSelection(info.model),
|
"mode",
|
||||||
default_agent: info.default_agent,
|
"agent",
|
||||||
autoupdate: info.autoupdate,
|
"provider",
|
||||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
"permission",
|
||||||
enterprise: info.enterprise,
|
"tools",
|
||||||
username: info.username,
|
"attachment",
|
||||||
permissions: permissions(info.permission, info.tools),
|
"layout",
|
||||||
agents: agents(info),
|
])
|
||||||
snapshots: info.snapshot,
|
|
||||||
watcher: info.watcher,
|
export function isV1(input: unknown) {
|
||||||
formatter: info.formatter,
|
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
|
||||||
lsp: info.lsp,
|
const record = input as Record<string, unknown>
|
||||||
media: info.attachment,
|
if (Object.keys(record).some((key) => keys.has(key))) return true
|
||||||
tool_output: info.tool_output,
|
// `mcp` exists in both versions, so presence alone is ambiguous: v1 lists servers directly under
|
||||||
mcp: mcp(info),
|
// `mcp`, while v2 nests them under `mcp.servers`. Only the v1 shape (a server entry with `type`)
|
||||||
compaction: info.compaction && {
|
// counts, so a bare `mcp`-only file still migrates instead of silently parsing to zero servers.
|
||||||
auto: info.compaction.auto,
|
const mcp = record.mcp
|
||||||
prune: info.compaction.prune,
|
return (
|
||||||
keep: {
|
typeof mcp === "object" &&
|
||||||
tokens: info.compaction.preserve_recent_tokens,
|
mcp !== null &&
|
||||||
},
|
!Array.isArray(mcp) &&
|
||||||
buffer: info.compaction.reserved,
|
!("servers" in mcp) &&
|
||||||
},
|
Object.values(mcp).some((server) => typeof server === "object" && server !== null && "type" in server)
|
||||||
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
|
|
||||||
commands: commands(info.command),
|
|
||||||
instructions: info.instructions,
|
|
||||||
references: info.references ?? info.reference,
|
|
||||||
experimental: experimental(info),
|
|
||||||
plugins: info.plugin?.map((plugin) =>
|
|
||||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
|
||||||
),
|
|
||||||
providers: providers(info.provider),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||||
|
return {
|
||||||
|
$schema: info.$schema,
|
||||||
|
shell: info.shell,
|
||||||
|
model: modelSelection(info.model),
|
||||||
|
default_agent: info.default_agent,
|
||||||
|
autoupdate: info.autoupdate,
|
||||||
|
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||||
|
enterprise: info.enterprise,
|
||||||
|
username: info.username,
|
||||||
|
permissions: permissions(info.permission, info.tools),
|
||||||
|
agents: agents(info),
|
||||||
|
snapshots: info.snapshot,
|
||||||
|
watcher: info.watcher,
|
||||||
|
formatter: info.formatter,
|
||||||
|
lsp: info.lsp,
|
||||||
|
media: info.attachment,
|
||||||
|
tool_output: info.tool_output,
|
||||||
|
mcp: mcp(info),
|
||||||
|
compaction: info.compaction && {
|
||||||
|
auto: info.compaction.auto,
|
||||||
|
prune: info.compaction.prune,
|
||||||
|
keep: {
|
||||||
|
tokens: info.compaction.preserve_recent_tokens,
|
||||||
|
},
|
||||||
|
buffer: info.compaction.reserved,
|
||||||
|
},
|
||||||
|
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
|
||||||
|
commands: commands(info.command),
|
||||||
|
instructions: info.instructions,
|
||||||
|
references: info.references ?? info.reference,
|
||||||
|
experimental: experimental(info),
|
||||||
|
plugins: info.plugin?.map((plugin) =>
|
||||||
|
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||||
|
),
|
||||||
|
providers: providers(info.provider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function experimental(info: typeof ConfigV1.Info.Type) {
|
function experimental(info: typeof ConfigV1.Info.Type) {
|
||||||
const policies = [
|
const policies = [
|
||||||
...(info.enabled_providers === undefined
|
...(info.enabled_providers === undefined
|
||||||
@@ -107,7 +132,7 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
|
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
|
||||||
export function normalizeAction(action: string) {
|
function normalizeAction(action: string) {
|
||||||
if (action === "write" || action === "patch") return "edit"
|
if (action === "write" || action === "patch") return "edit"
|
||||||
if (action === "task") return "subagent"
|
if (action === "task") return "subagent"
|
||||||
if (action === "bash") return "shell"
|
if (action === "bash") return "shell"
|
||||||
@@ -129,25 +154,21 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
|
|||||||
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
|
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
|
||||||
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
|
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
|
||||||
}
|
}
|
||||||
return encodeAgent(
|
return {
|
||||||
decodeAgent(
|
model: modelSelection(info.model, info.variant),
|
||||||
JSON.stringify({
|
request: Object.keys(body).length ? { body } : undefined,
|
||||||
model: modelSelection(info.model, info.variant),
|
system: info.prompt,
|
||||||
request: Object.keys(body).length ? { body } : undefined,
|
description: info.description,
|
||||||
system: info.prompt,
|
mode: info.mode,
|
||||||
description: info.description,
|
hidden: info.hidden,
|
||||||
mode: info.mode,
|
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
|
||||||
hidden: info.hidden,
|
steps: info.steps,
|
||||||
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
|
disabled: info.disable,
|
||||||
steps: info.steps,
|
permissions: permissions(info.permission),
|
||||||
disabled: info.disable,
|
}
|
||||||
permissions: permissions(info.permission),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||||
if (!info) return undefined
|
if (!info) return undefined
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(info).map(([id, command]) => [
|
Object.entries(info).map(([id, command]) => [
|
||||||
@@ -184,7 +205,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
|
|||||||
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
|
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrateMcp(info: ConfigMCPV1.Info) {
|
function migrateMcp(info: ConfigMCPV1.Info) {
|
||||||
const disabled = info.enabled === undefined ? undefined : !info.enabled
|
const disabled = info.enabled === undefined ? undefined : !info.enabled
|
||||||
if (info.type === "local")
|
if (info.type === "local")
|
||||||
return {
|
return {
|
||||||
@@ -223,7 +244,7 @@ function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
||||||
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
|
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
|
||||||
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
|
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
|
||||||
return migrateStandardProvider(info)
|
return migrateStandardProvider(info)
|
||||||
@@ -235,7 +256,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
|
|||||||
name: info.name,
|
name: info.name,
|
||||||
env: info.env,
|
env: info.env,
|
||||||
package: info.npm ? Provider.aisdk(info.npm) : undefined,
|
package: info.npm ? Provider.aisdk(info.npm) : undefined,
|
||||||
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
|
settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings,
|
||||||
headers: info.options && options.headers,
|
headers: info.options && options.headers,
|
||||||
body: info.options && options.body,
|
body: info.options && options.body,
|
||||||
models:
|
models:
|
||||||
@@ -279,8 +300,8 @@ function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename these only while migrating unambiguous V1 fields.
|
// Rename these only in files detected as V1 by a field that exists only in the old config format.
|
||||||
export function providerID(input: string) {
|
function providerID(input: string) {
|
||||||
if (input === "azure-cognitive-services") return "azure"
|
if (input === "azure-cognitive-services") return "azure"
|
||||||
if (input === "google-vertex-anthropic") return "google-vertex"
|
if (input === "google-vertex-anthropic") return "google-vertex"
|
||||||
return input
|
return input
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||||
import { advance, drain } from "../lib/clock"
|
import { advance, drain } from "../lib/clock"
|
||||||
@@ -51,11 +50,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||||||
it.effect("matches Windows paths against home-relative permissions", () =>
|
it.effect("matches Windows paths against home-relative permissions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const permissions = yield* loadHomePermissions("C:\\Users\\test")
|
const permissions = yield* loadHomePermissions("C:\\Users\\test")
|
||||||
expect(permissions).toContainEqual({
|
|
||||||
action: "external_directory",
|
|
||||||
resource: "C:\\Users\\test\\p\\**",
|
|
||||||
effect: "allow",
|
|
||||||
})
|
|
||||||
expect(
|
expect(
|
||||||
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
|
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
|
||||||
).toBe("allow")
|
).toBe("allow")
|
||||||
@@ -65,96 +59,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("applies remote permission defaults before explicit global and build rules", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const agents = yield* Agent.Service
|
|
||||||
const global = yield* Global.Service
|
|
||||||
yield* AgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
|
|
||||||
|
|
||||||
const entries = [
|
|
||||||
new Document({
|
|
||||||
type: "document",
|
|
||||||
info: decode(
|
|
||||||
ConfigMigrateV1.migrate({
|
|
||||||
permission: {
|
|
||||||
bash: "ask",
|
|
||||||
edit: "ask",
|
|
||||||
webfetch: "ask",
|
|
||||||
read: {
|
|
||||||
"*": "allow",
|
|
||||||
"*.env": "deny",
|
|
||||||
"*.env.*": "deny",
|
|
||||||
"*.env.example": "allow",
|
|
||||||
"*.dev.vars": "deny",
|
|
||||||
"~/.local/share/opencode/mcp-auth.json": "deny",
|
|
||||||
"$HOME/.local/share/opencode/mcp-auth.json": "deny",
|
|
||||||
},
|
|
||||||
external_directory: {
|
|
||||||
"*": "ask",
|
|
||||||
"~/.local/share/opencode/*": "deny",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
new Document({
|
|
||||||
type: "document",
|
|
||||||
info: decode({
|
|
||||||
permissions: [{ action: "*", resource: "*", effect: "allow" }],
|
|
||||||
agents: {
|
|
||||||
build: {
|
|
||||||
permissions: [
|
|
||||||
{ action: "external_directory", resource: "*", effect: "allow" },
|
|
||||||
{
|
|
||||||
action: "external_directory",
|
|
||||||
resource: "~/.local/share/opencode/*",
|
|
||||||
effect: "deny",
|
|
||||||
},
|
|
||||||
{ action: "read", resource: "*.env", effect: "deny" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
|
||||||
Effect.provide(Config.testLayer(entries)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const build = yield* agents.get(Agent.defaultID)
|
|
||||||
if (!build) throw new Error("expected configured build agent")
|
|
||||||
const opencodeData = path.join(global.home, ".local", "share", "opencode", "*")
|
|
||||||
const mcpAuth = path.join(global.home, ".local", "share", "opencode", "mcp-auth.json")
|
|
||||||
expect(build.permissions).toEqual([
|
|
||||||
...defaultPermissions(global),
|
|
||||||
{ action: "question", resource: "*", effect: "allow" },
|
|
||||||
{ action: "shell", resource: "*", effect: "ask" },
|
|
||||||
{ action: "edit", resource: "*", effect: "ask" },
|
|
||||||
{ action: "webfetch", resource: "*", effect: "ask" },
|
|
||||||
{ action: "read", resource: "*", effect: "allow" },
|
|
||||||
{ action: "read", resource: "*.env", effect: "deny" },
|
|
||||||
{ action: "read", resource: "*.env.*", effect: "deny" },
|
|
||||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
|
||||||
{ action: "read", resource: "*.dev.vars", effect: "deny" },
|
|
||||||
{ action: "read", resource: mcpAuth, effect: "deny" },
|
|
||||||
{ action: "read", resource: mcpAuth, effect: "deny" },
|
|
||||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
|
||||||
{ action: "external_directory", resource: opencodeData, effect: "deny" },
|
|
||||||
{ action: "*", resource: "*", effect: "allow" },
|
|
||||||
{ action: "external_directory", resource: "*", effect: "allow" },
|
|
||||||
{ action: "external_directory", resource: opencodeData, effect: "deny" },
|
|
||||||
{ action: "read", resource: "*.env", effect: "deny" },
|
|
||||||
])
|
|
||||||
expect(Permission.evaluate("shell", "bun test", build.permissions).effect).toBe("allow")
|
|
||||||
expect(Permission.evaluate("edit", "src/index.ts", build.permissions).effect).toBe("allow")
|
|
||||||
expect(Permission.evaluate("webfetch", "https://example.com", build.permissions).effect).toBe("allow")
|
|
||||||
expect(Permission.evaluate("read", ".env", build.permissions).effect).toBe("deny")
|
|
||||||
expect(Permission.evaluate("external_directory", opencodeData, build.permissions).effect).toBe("deny")
|
|
||||||
expect(Permission.evaluate("external_directory", "/outside/*", build.permissions).effect).toBe("allow")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("applies all global permissions before agent-specific permissions", () =>
|
it.effect("applies all global permissions before agent-specific permissions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
|
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
|
||||||
import { FastCheck } from "effect/testing"
|
import { FastCheck } from "effect/testing"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { Config } from "@opencode-ai/core/config"
|
||||||
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||||
@@ -307,7 +307,7 @@ describe("Config", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("loads authenticated wellknown config before user configuration", () =>
|
it.live("loads authenticated wellknown config at highest priority", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) =>
|
(tmp) =>
|
||||||
@@ -370,13 +370,7 @@ describe("Config", () => {
|
|||||||
return yield* Effect.gen(function* () {
|
return yield* Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const initial = yield* config.entries()
|
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
|
||||||
expect(Config.latest(initial, "shell")).toBe("project")
|
|
||||||
expect(
|
|
||||||
initial.flatMap((entry) =>
|
|
||||||
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
|
|
||||||
),
|
|
||||||
).toEqual(["secret", "global", "project"])
|
|
||||||
const updated = yield* bus
|
const updated = yield* bus
|
||||||
.subscribe(Event.Updated)
|
.subscribe(Event.Updated)
|
||||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
@@ -384,13 +378,7 @@ describe("Config", () => {
|
|||||||
key = "next"
|
key = "next"
|
||||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
||||||
expect(yield* Fiber.join(updated)).toHaveLength(1)
|
expect(yield* Fiber.join(updated)).toHaveLength(1)
|
||||||
const refreshed = yield* config.entries()
|
expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
|
||||||
expect(Config.latest(refreshed, "shell")).toBe("project")
|
|
||||||
expect(
|
|
||||||
refreshed.flatMap((entry) =>
|
|
||||||
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
|
|
||||||
),
|
|
||||||
).toEqual(["next", "global", "project"])
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
|
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
|
||||||
)
|
)
|
||||||
@@ -399,96 +387,27 @@ describe("Config", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("logs redacted source-aware diagnostics for every config source", () => {
|
it.effect("detects v1 configuration from any v1-only top-level key", () =>
|
||||||
const output: Array<Record<string, unknown>> = []
|
Effect.sync(() => {
|
||||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
|
||||||
if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return
|
expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
|
||||||
const details = entry.message[1]
|
expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
|
||||||
if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
|
expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
|
||||||
})
|
expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
|
||||||
return Effect.acquireUseRelease(
|
}),
|
||||||
Effect.promise(() => tmpdir()),
|
)
|
||||||
(tmp) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const global = path.join(tmp.path, "global")
|
|
||||||
const project = path.join(tmp.path, "project")
|
|
||||||
const malformed = path.join(tmp.path, "malformed.json")
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
await fs.mkdir(global, { recursive: true })
|
|
||||||
await fs.mkdir(project, { recursive: true })
|
|
||||||
await fs.writeFile(path.join(global, "opencode.json"), "null")
|
|
||||||
await fs.writeFile(path.join(project, "opencode.json"), "")
|
|
||||||
await fs.writeFile(malformed, '{ "credential": "file-secret"')
|
|
||||||
})
|
|
||||||
const integrationID = Integration.ID.make("https://invalid.example.com")
|
|
||||||
const entry: WellKnown.Entry = {
|
|
||||||
origin: "https://invalid.example.com",
|
|
||||||
integrationID,
|
|
||||||
manifest: { auth: { command: ["login"], env: "TOKEN" } },
|
|
||||||
}
|
|
||||||
const credentialNode = makeGlobalNode({
|
|
||||||
service: Credential.Service,
|
|
||||||
layer: Layer.succeed(
|
|
||||||
Credential.Service,
|
|
||||||
Credential.Service.of({
|
|
||||||
all: () => Effect.die("unused Credential.all"),
|
|
||||||
list: () =>
|
|
||||||
Effect.succeed([
|
|
||||||
new Credential.Info({
|
|
||||||
id: Credential.ID.create(),
|
|
||||||
integrationID,
|
|
||||||
label: "default",
|
|
||||||
value: Credential.Key.make({ type: "key", key: "wellknown-secret" }),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
get: () => Effect.die("unused Credential.get"),
|
|
||||||
create: () => Effect.die("unused Credential.create"),
|
|
||||||
update: () => Effect.die("unused Credential.update"),
|
|
||||||
remove: () => Effect.die("unused Credential.remove"),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
deps: [],
|
|
||||||
})
|
|
||||||
const wellknownNode = makeGlobalNode({
|
|
||||||
service: WellKnown.Service,
|
|
||||||
layer: Layer.succeed(
|
|
||||||
WellKnown.Service,
|
|
||||||
WellKnown.Service.of({
|
|
||||||
entries: () => Effect.succeed([entry]),
|
|
||||||
snapshot: () => [entry],
|
|
||||||
refresh: () => Effect.succeed(false),
|
|
||||||
add: () => Effect.die("unused Wellknown.add"),
|
|
||||||
remove: () => Effect.die("unused Wellknown.remove"),
|
|
||||||
// Exercise the loader boundary against a malformed implementation response.
|
|
||||||
resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
deps: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* Config.Service.use((config) => config.entries()).pipe(
|
it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () =>
|
||||||
Effect.provide(
|
Effect.sync(() => {
|
||||||
testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, {
|
// V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates.
|
||||||
file: malformed,
|
expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true)
|
||||||
content: "",
|
expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true)
|
||||||
}),
|
// Current config nests under `mcp.servers`, so it must not be misdetected and re-migrated.
|
||||||
),
|
expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
|
||||||
)
|
expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
|
||||||
|
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
|
||||||
expect(output.map((item) => `${item.source}:${item.path}:${item.kind}`).toSorted()).toEqual(
|
}),
|
||||||
[
|
)
|
||||||
`${path.join(global, "opencode.json")}:$:invalid`,
|
|
||||||
`${path.join(project, "opencode.json")}:$:invalid`,
|
|
||||||
`${malformed}:$:invalid`,
|
|
||||||
"https://invalid.example.com:$:invalid",
|
|
||||||
"OPENCODE_CONFIG_CONTENT:$:invalid",
|
|
||||||
].toSorted(),
|
|
||||||
)
|
|
||||||
expect(JSON.stringify(output)).not.toContain("secret")
|
|
||||||
}),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
).pipe(Effect.provide(Logger.layer([logger])))
|
|
||||||
})
|
|
||||||
|
|
||||||
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
|
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -597,12 +516,12 @@ describe("Config", () => {
|
|||||||
})
|
})
|
||||||
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
||||||
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
||||||
|
package: undefined,
|
||||||
settings: { project: "test-project", location: "us-central1" },
|
settings: { project: "test-project", location: "us-central1" },
|
||||||
models: {
|
models: {
|
||||||
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
|
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
|
|
||||||
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
|
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,489 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { Duration, Schema } from "effect"
|
|
||||||
import { FastCheck } from "effect/testing"
|
|
||||||
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
|
|
||||||
import { Info } from "@opencode-ai/schema/config"
|
|
||||||
|
|
||||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
|
||||||
|
|
||||||
function normalized(input: unknown) {
|
|
||||||
const result = ConfigNormalize.normalize(input)
|
|
||||||
expect(result.type).toBe("normalized")
|
|
||||||
if (result.type !== "normalized") throw new Error("expected normalized config")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function decoded(input: unknown) {
|
|
||||||
return Schema.decodeUnknownSync(Info, options)(normalized(input).encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
function withoutEmptyCompatibilityContainers(input: Record<string, unknown>) {
|
|
||||||
const result = structuredClone(input)
|
|
||||||
if (typeof result.mcp === "object" && result.mcp !== null && !Array.isArray(result.mcp)) {
|
|
||||||
const mcp = result.mcp as Record<string, unknown>
|
|
||||||
const originallyEmpty = !Object.keys(mcp).length
|
|
||||||
for (const key of ["servers", "timeout"]) {
|
|
||||||
if (
|
|
||||||
typeof mcp[key] === "object" &&
|
|
||||||
mcp[key] !== null &&
|
|
||||||
!Array.isArray(mcp[key]) &&
|
|
||||||
!Object.keys(mcp[key]).length
|
|
||||||
)
|
|
||||||
delete mcp[key]
|
|
||||||
}
|
|
||||||
if (!originallyEmpty && !Object.keys(mcp).length) delete result.mcp
|
|
||||||
}
|
|
||||||
if (typeof result.compaction === "object" && result.compaction !== null && !Array.isArray(result.compaction)) {
|
|
||||||
const compaction = result.compaction as Record<string, unknown>
|
|
||||||
const originallyEmpty = !Object.keys(compaction).length
|
|
||||||
if (
|
|
||||||
typeof compaction.keep === "object" &&
|
|
||||||
compaction.keep !== null &&
|
|
||||||
!Array.isArray(compaction.keep) &&
|
|
||||||
!Object.keys(compaction.keep).length
|
|
||||||
)
|
|
||||||
delete compaction.keep
|
|
||||||
if (!originallyEmpty && !Object.keys(compaction).length) delete result.compaction
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ConfigNormalize", () => {
|
|
||||||
test("rejects every non-object root with one root diagnostic", () => {
|
|
||||||
for (const input of [null, [], "config", true, 1]) {
|
|
||||||
expect(ConfigNormalize.normalize(input)).toEqual({
|
|
||||||
type: "rejected",
|
|
||||||
diagnostics: [
|
|
||||||
{
|
|
||||||
kind: "invalid",
|
|
||||||
path: ["$"],
|
|
||||||
message: "rejected configuration because its root is not an object",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps unrelated native fields when a legacy field is present", () => {
|
|
||||||
const result = decoded({ snapshot: false, agents: { reviewer: { system: "Use V2" } } })
|
|
||||||
expect(result.snapshots).toBe(false)
|
|
||||||
expect(result.agents?.reviewer?.system).toBe("Use V2")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("canonicalizes transformed native values through decode then encode", () => {
|
|
||||||
const result = normalized({ warming: { interval: "4 minutes", duration: "30 minutes" } })
|
|
||||||
expect(result.encoded.warming).toEqual({ interval: "240000 millis", duration: "1800000 millis" })
|
|
||||||
const info = Schema.decodeUnknownSync(Info)(result.encoded)
|
|
||||||
if (typeof info.warming === "boolean" || info.warming === undefined) throw new Error("expected warming info")
|
|
||||||
expect(Duration.toMillis(info.warming.interval ?? Duration.zero)).toBe(240_000)
|
|
||||||
expect(Duration.toMillis(info.warming.duration ?? Duration.zero)).toBe(1_800_000)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves arbitrary JSON-round-tripped native configuration", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(Schema.toArbitrary(Info), (info) => {
|
|
||||||
const source = JSON.parse(JSON.stringify(Schema.encodeSync(Info)(info)))
|
|
||||||
const result = normalized(source)
|
|
||||||
expect(Schema.decodeUnknownSync(Info)(result.encoded)).toEqual(
|
|
||||||
Schema.decodeUnknownSync(Info)(withoutEmptyCompatibilityContainers(source)),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("merges named maps by entry and gives valid native entries precedence", () => {
|
|
||||||
const result = normalized({
|
|
||||||
reference: { legacy: { path: "../legacy" }, duplicate: { path: "../old" } },
|
|
||||||
references: { native: { path: "../native" }, duplicate: { path: "../new" } },
|
|
||||||
command: { legacy: { template: "legacy" }, duplicate: { template: "old" } },
|
|
||||||
commands: { native: { template: "native" }, duplicate: { template: "new" } },
|
|
||||||
})
|
|
||||||
expect(result.encoded.references).toEqual({
|
|
||||||
legacy: { path: "../legacy" },
|
|
||||||
native: { path: "../native" },
|
|
||||||
duplicate: { path: "../new" },
|
|
||||||
})
|
|
||||||
expect(result.encoded.commands).toEqual({
|
|
||||||
legacy: { template: "legacy" },
|
|
||||||
native: { template: "native" },
|
|
||||||
duplicate: { template: "new" },
|
|
||||||
})
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
|
|
||||||
["references", "duplicate"],
|
|
||||||
["commands", "duplicate"],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not report canonical-equal duplicates as conflicts", () => {
|
|
||||||
const result = normalized({
|
|
||||||
snapshot: false,
|
|
||||||
snapshots: false,
|
|
||||||
reference: { docs: { path: "../docs" } },
|
|
||||||
references: { docs: { path: "../docs" } },
|
|
||||||
agent: { reviewer: { prompt: "same" } },
|
|
||||||
agents: { reviewer: { system: "same" } },
|
|
||||||
provider: { custom: { name: "same" } },
|
|
||||||
providers: { custom: { name: "same" } },
|
|
||||||
compaction: { preserve_recent_tokens: 1000, keep: { tokens: 1000 } },
|
|
||||||
})
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "conflict")).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses agent then mode then native agent precedence", () => {
|
|
||||||
const result = normalized({
|
|
||||||
agent: { reviewer: { prompt: "agent" }, agentOnly: { prompt: "agent-only" } },
|
|
||||||
mode: { reviewer: { prompt: "mode" }, modeOnly: { prompt: "mode-only" } },
|
|
||||||
agents: { reviewer: { system: "native" }, nativeOnly: { system: "native-only" } },
|
|
||||||
})
|
|
||||||
expect(result.encoded.agents).toEqual({
|
|
||||||
reviewer: { system: "native" },
|
|
||||||
agentOnly: { system: "agent-only" },
|
|
||||||
modeOnly: { system: "mode-only", mode: "primary" },
|
|
||||||
nativeOnly: { system: "native-only" },
|
|
||||||
})
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
|
|
||||||
["agents", "reviewer"],
|
|
||||||
["agents", "reviewer"],
|
|
||||||
])
|
|
||||||
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("recovers malformed named entries and retains a valid legacy collision", () => {
|
|
||||||
const result = normalized({
|
|
||||||
command: { fallback: { template: "legacy" } },
|
|
||||||
commands: {
|
|
||||||
fallback: { template: 1 },
|
|
||||||
valid: { template: "native" },
|
|
||||||
invalid: { template: false },
|
|
||||||
},
|
|
||||||
providers: {
|
|
||||||
valid: { name: "Valid" },
|
|
||||||
invalid: { env: [1] },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(result.encoded.commands).toEqual({ fallback: { template: "legacy" }, valid: { template: "native" } })
|
|
||||||
expect(result.encoded.providers).toEqual({ valid: { name: "Valid" } })
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
|
|
||||||
["commands", "fallback"],
|
|
||||||
["commands", "invalid"],
|
|
||||||
["providers", "invalid"],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses a valid retired provider alias when the canonical legacy entry is malformed", () => {
|
|
||||||
const result = normalized({
|
|
||||||
provider: {
|
|
||||||
"azure-cognitive-services": { models: { deployment: {} } },
|
|
||||||
azure: { env: [1] },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(result.encoded.providers).toHaveProperty("azure.models.deployment")
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toContainEqual([
|
|
||||||
"provider",
|
|
||||||
"azure",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves permission source order and appends native rules", () => {
|
|
||||||
expect(
|
|
||||||
normalized({
|
|
||||||
tools: { bash: true, write: false },
|
|
||||||
permission: { read: "allow", custom: { first: "deny", second: "ask" }, task: "allow" },
|
|
||||||
permissions: [{ action: "native", resource: "*", effect: "deny" }],
|
|
||||||
}).encoded.permissions,
|
|
||||||
).toEqual([
|
|
||||||
{ action: "shell", resource: "*", effect: "allow" },
|
|
||||||
{ action: "edit", resource: "*", effect: "deny" },
|
|
||||||
{ action: "read", resource: "*", effect: "allow" },
|
|
||||||
{ action: "custom", resource: "first", effect: "deny" },
|
|
||||||
{ action: "custom", resource: "second", effect: "ask" },
|
|
||||||
{ action: "subagent", resource: "*", effect: "allow" },
|
|
||||||
{ action: "native", resource: "*", effect: "deny" },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("redacts permission resource keys from invalid diagnostics", () => {
|
|
||||||
const result = normalized({
|
|
||||||
permission: { bash: { "curl -H Authorization:Bearer TOPSECRET *": "bogus" } },
|
|
||||||
})
|
|
||||||
expect(result.diagnostics).toEqual([
|
|
||||||
{
|
|
||||||
kind: "invalid",
|
|
||||||
path: ["permission", "bash", "0"],
|
|
||||||
message: "skipped malformed recognized value",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(JSON.stringify(result.diagnostics)).not.toContain("TOPSECRET")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("recovers list items for skills, plugins, instructions, and permissions", () => {
|
|
||||||
const result = normalized({
|
|
||||||
skills: { paths: ["./skills", 1], urls: [false, "https://example.com/skills"] },
|
|
||||||
plugin: ["legacy", ["tuple", {}], [1, {}]],
|
|
||||||
plugins: ["native", { package: "object" }, { package: 1 }],
|
|
||||||
instructions: ["one", 2, "three"],
|
|
||||||
permissions: [
|
|
||||||
{ action: "read", resource: "*", effect: "allow" },
|
|
||||||
{ action: "read", resource: "*", effect: "invalid" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(result.encoded.skills).toEqual(["./skills", "https://example.com/skills"])
|
|
||||||
expect(result.encoded.plugins).toEqual([
|
|
||||||
"legacy",
|
|
||||||
{ package: "tuple", options: {} },
|
|
||||||
"native",
|
|
||||||
{ package: "object" },
|
|
||||||
])
|
|
||||||
expect(result.encoded.instructions).toEqual(["one", "three"])
|
|
||||||
expect(result.encoded.permissions).toEqual([{ action: "read", resource: "*", effect: "allow" }])
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "invalid")).toHaveLength(6)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("omits malformed collection roots instead of synthesizing empty values", () => {
|
|
||||||
const result = normalized({
|
|
||||||
commands: [],
|
|
||||||
providers: "invalid",
|
|
||||||
references: false,
|
|
||||||
agents: 1,
|
|
||||||
plugins: {},
|
|
||||||
permissions: {},
|
|
||||||
instructions: {},
|
|
||||||
})
|
|
||||||
expect(result.encoded).toEqual({})
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
|
|
||||||
["references"],
|
|
||||||
["commands"],
|
|
||||||
["agents"],
|
|
||||||
["providers"],
|
|
||||||
["permissions"],
|
|
||||||
["plugins"],
|
|
||||||
["instructions"],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("omits all-invalid formatter and LSP maps while preserving explicit empty maps", () => {
|
|
||||||
const invalid = normalized({
|
|
||||||
formatter: { prettier: { command: [1] } },
|
|
||||||
lsp: { typescript: { command: [1] } },
|
|
||||||
})
|
|
||||||
expect(invalid.encoded).not.toHaveProperty("formatter")
|
|
||||||
expect(invalid.encoded).not.toHaveProperty("lsp")
|
|
||||||
expect(invalid.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
|
|
||||||
["formatter", "prettier"],
|
|
||||||
["lsp", "typescript"],
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(normalized({ formatter: {}, lsp: {} }).encoded).toMatchObject({ formatter: {}, lsp: {} })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("combines legacy and native MCP servers and merges timeout leaves", () => {
|
|
||||||
const result = normalized({
|
|
||||||
experimental: { mcp_timeout: 5000 },
|
|
||||||
mcp: {
|
|
||||||
legacy: { type: "local", command: ["legacy"] },
|
|
||||||
duplicate: { type: "remote", url: "https://legacy.example.com" },
|
|
||||||
servers: {
|
|
||||||
native: { type: "local", command: ["native"] },
|
|
||||||
duplicate: { type: "remote", url: "https://native.example.com" },
|
|
||||||
invalid: { type: "local", command: [1] },
|
|
||||||
},
|
|
||||||
timeout: { startup: 1000, catalog: 6000 },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(result.encoded.mcp).toEqual({
|
|
||||||
timeout: { catalog: 6000, execution: 5000, startup: 1000 },
|
|
||||||
servers: {
|
|
||||||
legacy: { type: "local", command: ["legacy"], disabled: undefined, timeout: undefined },
|
|
||||||
duplicate: { type: "remote", url: "https://native.example.com" },
|
|
||||||
native: { type: "local", command: ["native"] },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.servers.duplicate"),
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.timeout.catalog"),
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
result.diagnostics.some((item) => item.kind === "invalid" && item.path.join(".") === "mcp.servers.invalid"),
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses raw MCP discriminators for reserved server names", () => {
|
|
||||||
const result = normalized({
|
|
||||||
mcp: {
|
|
||||||
servers: { type: "local", command: ["reserved-servers"] },
|
|
||||||
timeout: { type: "remote", url: "https://reserved.example.com" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect((result.encoded.mcp as { servers: Record<string, unknown> }).servers).toEqual({
|
|
||||||
servers: { type: "local", command: ["reserved-servers"], disabled: undefined, timeout: undefined },
|
|
||||||
timeout: { type: "remote", url: "https://reserved.example.com", disabled: undefined, timeout: undefined },
|
|
||||||
})
|
|
||||||
|
|
||||||
const enabledOnly = normalized({ mcp: { servers: { enabled: true }, timeout: { enabled: false } } })
|
|
||||||
expect(enabledOnly.encoded.mcp).toBeUndefined()
|
|
||||||
expect(enabledOnly.diagnostics.map((item) => [item.kind, item.path])).toEqual([
|
|
||||||
["unsupported", ["mcp", "servers"]],
|
|
||||||
["unsupported", ["mcp", "timeout"]],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("merges bounded compaction leaves and omits unsupported leaves", () => {
|
|
||||||
const result = normalized({
|
|
||||||
compaction: {
|
|
||||||
auto: false,
|
|
||||||
preserve_recent_tokens: 1000,
|
|
||||||
keep: { tokens: 2000 },
|
|
||||||
reserved: 3000,
|
|
||||||
buffer: 4000,
|
|
||||||
tail_turns: 2,
|
|
||||||
prune: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(result.encoded.compaction).toEqual({ auto: false, keep: { tokens: 2000 }, buffer: 4000 })
|
|
||||||
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([
|
|
||||||
["unsupported", ["compaction", "tail_turns"]],
|
|
||||||
["unsupported", ["compaction", "prune"]],
|
|
||||||
["conflict", ["compaction", "keep", "tokens"]],
|
|
||||||
["conflict", ["compaction", "buffer"]],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("distinguishes empty, mixed, and wholly malformed enabled provider lists", () => {
|
|
||||||
expect(normalized({ enabled_providers: [] }).encoded.experimental).toEqual({
|
|
||||||
policies: [{ action: "provider.use", resource: "*", effect: "deny" }],
|
|
||||||
})
|
|
||||||
expect(normalized({ enabled_providers: [1, "anthropic", false] }).encoded.experimental).toEqual({
|
|
||||||
policies: [
|
|
||||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
|
||||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(normalized({ enabled_providers: [1, false] }).encoded.experimental).toBeUndefined()
|
|
||||||
expect(normalized({ enabled_providers: "anthropic" }).encoded.experimental).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("appends native policies after migrated provider policies", () => {
|
|
||||||
expect(
|
|
||||||
normalized({
|
|
||||||
enabled_providers: ["anthropic"],
|
|
||||||
disabled_providers: ["openai"],
|
|
||||||
experimental: {
|
|
||||||
subagent_depth: 0,
|
|
||||||
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
|
|
||||||
},
|
|
||||||
}).encoded.experimental,
|
|
||||||
).toEqual({
|
|
||||||
subagent_depth: 0,
|
|
||||||
policies: [
|
|
||||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
|
||||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
|
||||||
{ action: "provider.use", resource: "openai", effect: "deny" },
|
|
||||||
{ action: "provider.use", resource: "custom", effect: "allow" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("reports unsupported legacy settings without including their values", () => {
|
|
||||||
const secret = "do-not-log-this-value"
|
|
||||||
const result = normalized({
|
|
||||||
logLevel: "DEBUG",
|
|
||||||
small_model: secret,
|
|
||||||
agent: { reviewer: { name: secret, prompt: "review" } },
|
|
||||||
provider: {
|
|
||||||
custom: {
|
|
||||||
id: secret,
|
|
||||||
whitelist: ["model"],
|
|
||||||
models: {
|
|
||||||
model: {
|
|
||||||
release_date: secret,
|
|
||||||
status: "active",
|
|
||||||
interleaved: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
experimental: { openTelemetry: true },
|
|
||||||
})
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
|
|
||||||
["logLevel"],
|
|
||||||
["small_model"],
|
|
||||||
["agent", "reviewer", "name"],
|
|
||||||
["provider", "custom", "id"],
|
|
||||||
["provider", "custom", "whitelist"],
|
|
||||||
["provider", "custom", "models", "model", "release_date"],
|
|
||||||
["provider", "custom", "models", "model", "status"],
|
|
||||||
["provider", "custom", "models", "model", "interleaved"],
|
|
||||||
["experimental", "openTelemetry"],
|
|
||||||
])
|
|
||||||
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("diagnoses unsupported legacy model selections without dropping their entries", () => {
|
|
||||||
const result = normalized({
|
|
||||||
command: {
|
|
||||||
invalidModel: { template: "one", model: "invalid" },
|
|
||||||
invalidVariant: { template: "two", model: "anthropic/model", variant: "bad#variant" },
|
|
||||||
missingModel: { template: "three", variant: "high" },
|
|
||||||
},
|
|
||||||
agent: { invalid: { prompt: "agent", model: "invalid", variant: "" } },
|
|
||||||
})
|
|
||||||
expect(Object.keys(result.encoded.commands as Record<string, unknown>)).toEqual([
|
|
||||||
"invalidModel",
|
|
||||||
"invalidVariant",
|
|
||||||
"missingModel",
|
|
||||||
])
|
|
||||||
expect(Object.keys(result.encoded.agents as Record<string, unknown>)).toEqual(["invalid"])
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
|
|
||||||
["command", "invalidModel", "model"],
|
|
||||||
["command", "invalidVariant", "variant"],
|
|
||||||
["command", "missingModel", "variant"],
|
|
||||||
["agent", "invalid", "model"],
|
|
||||||
["agent", "invalid", "variant"],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("invalid legacy provider overlays skip only that provider", () => {
|
|
||||||
const result = normalized({
|
|
||||||
provider: {
|
|
||||||
headers: { options: { headers: { valid: "yes", invalid: 1 } } },
|
|
||||||
body: { options: { body: "not-an-object" } },
|
|
||||||
valid: { options: { headers: { valid: "yes" }, body: { trace: true } } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(result.encoded.providers).toEqual({
|
|
||||||
valid: { settings: {}, headers: { valid: "yes" }, body: { trace: true } },
|
|
||||||
})
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
|
|
||||||
["provider", "headers", "options", "headers"],
|
|
||||||
["provider", "body", "options", "body"],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves explicit false, zero, empty list, and empty map presence", () => {
|
|
||||||
const result = normalized({
|
|
||||||
snapshot: false,
|
|
||||||
autoshare: false,
|
|
||||||
references: {},
|
|
||||||
commands: {},
|
|
||||||
agents: {},
|
|
||||||
providers: {},
|
|
||||||
plugins: [],
|
|
||||||
instructions: [],
|
|
||||||
experimental: { subagent_depth: 0 },
|
|
||||||
})
|
|
||||||
expect(result.encoded).toMatchObject({
|
|
||||||
snapshots: false,
|
|
||||||
references: {},
|
|
||||||
commands: {},
|
|
||||||
agents: {},
|
|
||||||
providers: {},
|
|
||||||
plugins: [],
|
|
||||||
instructions: [],
|
|
||||||
experimental: { subagent_depth: 0 },
|
|
||||||
})
|
|
||||||
expect(result.encoded.share).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -237,11 +237,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||||||
models: {
|
models: {
|
||||||
chat: {
|
chat: {
|
||||||
name: "First",
|
name: "First",
|
||||||
compatibility: {
|
compatibility: { reasoningField: "vendor_reasoning" },
|
||||||
reasoningField: "vendor_reasoning",
|
|
||||||
maxTokensField: "max_completion_tokens",
|
|
||||||
requireFinishReason: false,
|
|
||||||
},
|
|
||||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||||
disabled: true,
|
disabled: true,
|
||||||
limit: { context: 100, output: 50 },
|
limit: { context: 100, output: 50 },
|
||||||
@@ -322,11 +318,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||||||
expect(model.id).toBe(modelID)
|
expect(model.id).toBe(modelID)
|
||||||
expect(model.modelID).toBe(Model.ID.make("api-chat"))
|
expect(model.modelID).toBe(Model.ID.make("api-chat"))
|
||||||
expect(model.name).toBe("Last")
|
expect(model.name).toBe("Last")
|
||||||
expect(model.compatibility).toEqual({
|
expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" })
|
||||||
reasoningField: "vendor_reasoning",
|
|
||||||
maxTokensField: "max_completion_tokens",
|
|
||||||
requireFinishReason: false,
|
|
||||||
})
|
|
||||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||||
expect(model.enabled).toBe(false)
|
expect(model.enabled).toBe(false)
|
||||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const input = {
|
|||||||
id: formID,
|
id: formID,
|
||||||
sessionID: SessionSchema.ID.make("ses_test"),
|
sessionID: SessionSchema.ID.make("ses_test"),
|
||||||
title: "Test form",
|
title: "Test form",
|
||||||
|
coalesce: "test-form",
|
||||||
fields: [{ key: "name", type: "string", required: true }],
|
fields: [{ key: "name", type: "string", required: true }],
|
||||||
} satisfies Form.CreateInput
|
} satisfies Form.CreateInput
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ describe("Form", () => {
|
|||||||
yield* Effect.addFinalizer(() => unsubscribe)
|
yield* Effect.addFinalizer(() => unsubscribe)
|
||||||
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
||||||
const form = yield* Deferred.await(created)
|
const form = yield* Deferred.await(created)
|
||||||
|
expect(form.coalesce).toBe("test-form")
|
||||||
|
|
||||||
yield* service.cancel(form.id)
|
yield* service.cancel(form.id)
|
||||||
|
|
||||||
|
|||||||
@@ -151,9 +151,6 @@ describe("ModelResolver", () => {
|
|||||||
http: { body: { custom_extension: { enabled: true } } },
|
http: { body: { custom_extension: { enabled: true } } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
|
||||||
expect(prepared.body.max_output_tokens).toBeUndefined()
|
|
||||||
expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -194,11 +191,7 @@ describe("ModelResolver", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||||
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||||
compatibility: {
|
compatibility: { reasoningField: "vendor_reasoning" },
|
||||||
reasoningField: "vendor_reasoning",
|
|
||||||
maxTokensField: "max_completion_tokens",
|
|
||||||
requireFinishReason: false,
|
|
||||||
},
|
|
||||||
settings: {
|
settings: {
|
||||||
apiKey: "settings-secret",
|
apiKey: "settings-secret",
|
||||||
baseURL: "https://compatible.example/v1",
|
baseURL: "https://compatible.example/v1",
|
||||||
@@ -208,8 +201,7 @@ describe("ModelResolver", () => {
|
|||||||
body: {},
|
body: {},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const request = LLM.request({ model: resolved, prompt: "Hello", generation: { maxTokens: 10 } })
|
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||||
const prepared = yield* compileRequest(request)
|
|
||||||
const headers = yield* resolved.route.auth.apply({
|
const headers = yield* resolved.route.auth.apply({
|
||||||
request,
|
request,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -221,10 +213,6 @@ describe("ModelResolver", () => {
|
|||||||
expect(headers.authorization).toBe("Bearer settings-secret")
|
expect(headers.authorization).toBe("Bearer settings-secret")
|
||||||
expect(resolved.route.id).toBe("openai-compatible-chat")
|
expect(resolved.route.id).toBe("openai-compatible-chat")
|
||||||
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
|
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
|
||||||
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
|
|
||||||
expect(resolved.compatibility?.requireFinishReason).toBe(false)
|
|
||||||
expect(prepared.body).toMatchObject({ max_completion_tokens: 10 })
|
|
||||||
expect(prepared.body).not.toHaveProperty("max_tokens")
|
|
||||||
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
|
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
|
||||||
expect(resolved.route.defaults.http?.body).toEqual({})
|
expect(resolved.route.defaults.http?.body).toEqual({})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||||
import { DateTime, Effect, Schema } from "effect"
|
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||||
import { Agent } from "@opencode-ai/core/agent"
|
import { Agent } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Model } from "@opencode-ai/core/model"
|
import { Model } from "@opencode-ai/core/model"
|
||||||
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
|||||||
import { Tool } from "@opencode-ai/core/tool"
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { PluginTestLayer } from "./fixture"
|
import { PluginTestLayer } from "./fixture"
|
||||||
import { host as testHost } from "./host"
|
import { host as testHost } from "./host"
|
||||||
@@ -223,45 +223,102 @@ describe("fromPromise", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("adapts promise session HTTP request and response hooks", () =>
|
it.effect("adapts promise session HTTP hooks", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
|
const host = yield* PluginHost.make(plugin)
|
||||||
|
const bodies: string[] = []
|
||||||
|
yield* PluginPromise.fromPromise(
|
||||||
|
define({
|
||||||
|
id: "promise-session-http",
|
||||||
|
setup: async (ctx) => {
|
||||||
|
await ctx.session.hook("http", (event) => {
|
||||||
|
event.use(async (request, next) => {
|
||||||
|
request.headers.set("x-hook", "promise")
|
||||||
|
await next(request)
|
||||||
|
const response = await next(request)
|
||||||
|
return new Response(`${await response.text()}-response`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await ctx.session.hook("http", (event) => {
|
||||||
|
event.use(async (request, next) => {
|
||||||
|
const response = await next(request)
|
||||||
|
return new Response(`${await response.text()}-outer`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).effect(host)
|
||||||
|
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||||
|
const event: PluginHooks.Domains["session"]["http"] = {
|
||||||
|
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||||
|
use: (item) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
middlewares.push(item)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* hooks.trigger("session", "http", event)
|
||||||
|
const request = middlewares.reduce<SessionHttpHandler>(
|
||||||
|
(next, item) => (input: Request) => item(input, next),
|
||||||
|
(input: Request) =>
|
||||||
|
Effect.promise(() => input.text()).pipe(
|
||||||
|
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
|
||||||
|
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
|
||||||
|
|
||||||
|
expect(bodies).toEqual(["payload", "payload"])
|
||||||
|
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const hooks = yield* PluginHooks.Service
|
const hooks = yield* PluginHooks.Service
|
||||||
const host = yield* PluginHost.make(plugin)
|
const host = yield* PluginHost.make(plugin)
|
||||||
yield* PluginPromise.fromPromise(
|
yield* PluginPromise.fromPromise(
|
||||||
define({
|
define({
|
||||||
id: "promise-session-http",
|
id: "promise-session-http-interrupt",
|
||||||
setup: async (ctx) => {
|
setup: async (ctx) => {
|
||||||
await ctx.session.hook("http.request", (event) => {
|
await ctx.session.hook("http", (event) => {
|
||||||
event.request = new Request("https://provider.test/changed", event.request)
|
event.use((request, next) => next(request))
|
||||||
event.request.headers.set("x-hook", "promise")
|
|
||||||
})
|
|
||||||
await ctx.session.hook("http.response", async (event) => {
|
|
||||||
event.response = new Response(`${await event.response.text()}-response`, {
|
|
||||||
status: event.response.status,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).effect(host)
|
).effect(host)
|
||||||
const context = {
|
const started = yield* Deferred.make<void>()
|
||||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
const interrupted = yield* Deferred.make<void>()
|
||||||
|
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||||
|
const event: PluginHooks.Domains["session"]["http"] = {
|
||||||
|
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
|
||||||
agent: Agent.ID.make("build"),
|
agent: Agent.ID.make("build"),
|
||||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||||
|
use: (item) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
middlewares.push(item)
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = yield* hooks.trigger("session", "http.request", {
|
yield* hooks.trigger("session", "http", event)
|
||||||
...context,
|
const request = middlewares.reduce<SessionHttpHandler>(
|
||||||
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
|
(next, item) => (input: Request) => item(input, next),
|
||||||
})
|
() =>
|
||||||
const response = yield* hooks.trigger("session", "http.response", {
|
Deferred.succeed(started, undefined).pipe(
|
||||||
...context,
|
Effect.andThen(Effect.never),
|
||||||
request: request.request,
|
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||||
response: new Response(request.request.headers.get("x-hook") ?? "missing"),
|
),
|
||||||
})
|
)
|
||||||
|
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
yield* Fiber.interrupt(fiber)
|
||||||
|
|
||||||
expect(request.request.url).toBe("https://provider.test/changed")
|
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
|
||||||
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
|
|||||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
|
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { PluginTestLayer } from "./fixture"
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
@@ -30,13 +31,26 @@ function required<T>(value: T | undefined): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||||
|
yield* (yield* PluginHooks.Service).trigger("session", "http", {
|
||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
agent: Agent.ID.make("build"),
|
agent: Agent.ID.make("build"),
|
||||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||||
request: new Request(url, { method: "POST", body: "{}" }),
|
use: (item) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
middlewares.push(item)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
|
const request = middlewares.reduce<SessionHttpHandler>(
|
||||||
|
(next, item) => (input: Request) => item(input, next),
|
||||||
|
(input: Request) => {
|
||||||
|
const headers = new Headers(input.headers)
|
||||||
|
headers.set("x-seen-url", input.url)
|
||||||
|
return Effect.succeed(new Response(null, { headers }))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
|
||||||
|
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("OpenAIPlugin", () => {
|
describe("OpenAIPlugin", () => {
|
||||||
@@ -126,7 +140,7 @@ describe("OpenAIPlugin", () => {
|
|||||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||||
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||||
expect(eligible.cost).toEqual([])
|
expect(eligible.cost).toEqual([])
|
||||||
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||||
expect(eligible.enabled).toBe(true)
|
expect(eligible.enabled).toBe(true)
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||||
false,
|
false,
|
||||||
@@ -135,14 +149,14 @@ describe("OpenAIPlugin", () => {
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
||||||
context: 400_000,
|
context: 272_000,
|
||||||
input: 272_000,
|
input: 272_000,
|
||||||
output: 64_000,
|
output: 64_000,
|
||||||
})
|
})
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
||||||
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
||||||
expect(gpt56.enabled).toBe(true)
|
expect(gpt56.enabled).toBe(true)
|
||||||
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
|||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
|
||||||
import { Workspace } from "@opencode-ai/core/workspace"
|
import { Workspace } from "@opencode-ai/core/workspace"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
@@ -38,14 +37,7 @@ const projects = Layer.succeed(
|
|||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([
|
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||||
Database.node,
|
|
||||||
Bus.node,
|
|
||||||
SessionProjector.node,
|
|
||||||
SessionStore.node,
|
|
||||||
Session.node,
|
|
||||||
SessionTransfer.node,
|
|
||||||
]),
|
|
||||||
[
|
[
|
||||||
[Bus.node, Bus.configured({ persist: true })],
|
[Bus.node, Bus.configured({ persist: true })],
|
||||||
[Project.node, projects],
|
[Project.node, projects],
|
||||||
@@ -748,77 +740,3 @@ describe("Session.create", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("SessionTransfer", () => {
|
|
||||||
it.effect("imports projected messages and reserves their aggregate sequence", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const session = yield* Session.Service
|
|
||||||
const transfer = yield* SessionTransfer.Service
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
const template = yield* session.create({ location, title: "Exported" })
|
|
||||||
const sessionID = Session.ID.create()
|
|
||||||
const sourceMessageID = SessionMessage.ID.create()
|
|
||||||
const errorMessageID = SessionMessage.ID.create()
|
|
||||||
|
|
||||||
const imported = yield* transfer.import({
|
|
||||||
data: {
|
|
||||||
info: { ...template, id: sessionID },
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: sourceMessageID,
|
|
||||||
type: "user",
|
|
||||||
text: "Imported message",
|
|
||||||
time: { created: DateTime.makeUnsafe(100) },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: errorMessageID,
|
|
||||||
type: "compaction",
|
|
||||||
status: "failed",
|
|
||||||
reason: "manual",
|
|
||||||
error: { type: "test_error", message: "Original error" },
|
|
||||||
time: { created: DateTime.makeUnsafe(101) },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
location,
|
|
||||||
})
|
|
||||||
const messages = yield* session.messages({ sessionID, order: "asc" })
|
|
||||||
|
|
||||||
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
|
|
||||||
expect(messages).toMatchObject([
|
|
||||||
{ id: sourceMessageID, type: "user", text: "Imported message" },
|
|
||||||
{ id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
|
|
||||||
])
|
|
||||||
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
|
|
||||||
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
|
|
||||||
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
|
|
||||||
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
|
|
||||||
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
|
|
||||||
])
|
|
||||||
|
|
||||||
yield* session.prompt({ sessionID, text: "Continue", resume: false })
|
|
||||||
yield* SessionPending.promote(db, bus, sessionID, "steer")
|
|
||||||
|
|
||||||
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.type)).toEqual([
|
|
||||||
"user",
|
|
||||||
"compaction",
|
|
||||||
"user",
|
|
||||||
])
|
|
||||||
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("rejects an existing session ID without changing its transcript", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const session = yield* Session.Service
|
|
||||||
const transfer = yield* SessionTransfer.Service
|
|
||||||
const existing = yield* session.create({ location, title: "Existing" })
|
|
||||||
const exit = yield* Effect.exit(transfer.import({ data: { info: existing, messages: [] }, location }))
|
|
||||||
|
|
||||||
expect(exit._tag).toBe("Failure")
|
|
||||||
expect((yield* session.get(existing.id)).title).toBe("Existing")
|
|
||||||
expect(yield* session.messages({ sessionID: existing.id })).toEqual([])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -255,7 +255,6 @@ describe("SessionRunnerLLM recorded", () => {
|
|||||||
describe("SessionModelRequest HTTP bridge", () => {
|
describe("SessionModelRequest HTTP bridge", () => {
|
||||||
const bodies: Uint8Array[] = []
|
const bodies: Uint8Array[] = []
|
||||||
const methods: string[] = []
|
const methods: string[] = []
|
||||||
const headers: Array<string | undefined> = []
|
|
||||||
const response = [
|
const response = [
|
||||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
||||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
||||||
@@ -269,7 +268,6 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||||
methods.push(request.method)
|
methods.push(request.method)
|
||||||
bodies.push(request.body.body.slice())
|
bodies.push(request.body.body.slice())
|
||||||
headers.push(request.headers["x-hook"])
|
|
||||||
return HttpClientResponse.fromWeb(
|
return HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
||||||
@@ -277,16 +275,14 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const httpIt = testEffect(
|
const retryIt = testEffect(
|
||||||
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
||||||
)
|
)
|
||||||
|
|
||||||
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
|
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
bodies.length = 0
|
bodies.length = 0
|
||||||
methods.length = 0
|
methods.length = 0
|
||||||
headers.length = 0
|
|
||||||
const seen: string[] = []
|
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const hooks = yield* PluginHooks.Service
|
const hooks = yield* PluginHooks.Service
|
||||||
@@ -301,20 +297,13 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
catalog: catalogHost(catalog),
|
catalog: catalogHost(catalog),
|
||||||
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
||||||
})
|
})
|
||||||
yield* pluginHost.session.hook("http.request", (event) =>
|
yield* pluginHost.session.hook("http", (event) =>
|
||||||
Effect.sync(() => {
|
event.use((request, next) =>
|
||||||
seen.push("request")
|
Effect.gen(function* () {
|
||||||
event.request.headers.set("x-hook", "effect")
|
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
|
||||||
}),
|
return yield* next(request)
|
||||||
)
|
}),
|
||||||
yield* pluginHost.session.hook("http.response", (event) =>
|
),
|
||||||
Effect.gen(function* () {
|
|
||||||
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
|
|
||||||
event.response = new Response(
|
|
||||||
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
|
|
||||||
event.response,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -342,15 +331,10 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
|
|
||||||
yield* session.resume(retrySessionID)
|
yield* session.resume(retrySessionID)
|
||||||
|
|
||||||
expect(methods).toEqual(["POST"])
|
expect(methods).toEqual(["POST", "POST"])
|
||||||
expect(headers).toEqual(["effect"])
|
expect(bodies).toHaveLength(2)
|
||||||
expect(seen).toEqual(["request", "response:200:effect"])
|
|
||||||
expect(bodies).toHaveLength(1)
|
|
||||||
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
||||||
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
|
expect(bodies[1]).toEqual(bodies[0])
|
||||||
type: "assistant",
|
|
||||||
content: [{ type: "text", text: "Hooked!" }],
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect } from "bun:test"
|
import { beforeEach, describe, expect } from "bun:test"
|
||||||
import { Deferred, Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
@@ -39,8 +39,6 @@ const providers = [
|
|||||||
let providerRequired = false
|
let providerRequired = false
|
||||||
let formResponse: Form.TerminalState = { status: "cancelled" }
|
let formResponse: Form.TerminalState = { status: "cancelled" }
|
||||||
const formResponses: Form.TerminalState[] = []
|
const formResponses: Form.TerminalState[] = []
|
||||||
let queryBarrier: Deferred.Deferred<void> | undefined
|
|
||||||
let synchronizedQueries = 0
|
|
||||||
let result = new WebSearch.Response({
|
let result = new WebSearch.Response({
|
||||||
providerID: WebSearch.ID.make("exa"),
|
providerID: WebSearch.ID.make("exa"),
|
||||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||||
@@ -54,8 +52,6 @@ beforeEach(() => {
|
|||||||
providerRequired = false
|
providerRequired = false
|
||||||
formResponse = { status: "cancelled" }
|
formResponse = { status: "cancelled" }
|
||||||
formResponses.length = 0
|
formResponses.length = 0
|
||||||
queryBarrier = undefined
|
|
||||||
synchronizedQueries = 0
|
|
||||||
result = new WebSearch.Response({
|
result = new WebSearch.Response({
|
||||||
providerID: WebSearch.ID.make("exa"),
|
providerID: WebSearch.ID.make("exa"),
|
||||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||||
@@ -79,21 +75,11 @@ const websearch = Layer.succeed(
|
|||||||
transform: () => Effect.die("unused"),
|
transform: () => Effect.die("unused"),
|
||||||
reload: () => Effect.die("unused"),
|
reload: () => Effect.die("unused"),
|
||||||
providers: () => Effect.succeed(providers),
|
providers: () => Effect.succeed(providers),
|
||||||
default: () =>
|
default: () => Effect.succeed(undefined),
|
||||||
Effect.gen(function* () {
|
|
||||||
const stored = values.get("websearch:provider")
|
|
||||||
if (stored === false) return yield* new WebSearch.DisabledError()
|
|
||||||
return typeof stored === "string" ? providers.find((provider) => provider.id === stored) : undefined
|
|
||||||
}),
|
|
||||||
query: (input) =>
|
query: (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
queries.push(input)
|
queries.push(input)
|
||||||
const stored = values.get("websearch:provider")
|
const stored = values.get("websearch:provider")
|
||||||
if (queryBarrier && synchronizedQueries < 5) {
|
|
||||||
synchronizedQueries++
|
|
||||||
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
|
|
||||||
yield* Deferred.await(queryBarrier)
|
|
||||||
}
|
|
||||||
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
|
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
|
||||||
if (typeof stored === "string")
|
if (typeof stored === "string")
|
||||||
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
||||||
@@ -255,6 +241,7 @@ describe("WebSearchTool registration", () => {
|
|||||||
{
|
{
|
||||||
sessionID,
|
sessionID,
|
||||||
title: "Web Search",
|
title: "Web Search",
|
||||||
|
coalesce: "msg_tool_test:websearch-consent",
|
||||||
metadata: { kind: "websearch.provider" },
|
metadata: { kind: "websearch.provider" },
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -312,6 +299,7 @@ describe("WebSearchTool registration", () => {
|
|||||||
expect(formRequests[1]).toEqual({
|
expect(formRequests[1]).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
title: "Choose a web search provider",
|
title: "Choose a web search provider",
|
||||||
|
coalesce: "msg_tool_test:websearch-provider",
|
||||||
metadata: { kind: "websearch.provider" },
|
metadata: { kind: "websearch.provider" },
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -330,35 +318,6 @@ describe("WebSearchTool registration", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("shares provider consent across concurrent searches", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
providerRequired = true
|
|
||||||
formResponse = { status: "answered", answer: { choice: "allow" } }
|
|
||||||
queryBarrier = yield* Deferred.make<void>()
|
|
||||||
const registry = yield* Tool.Service
|
|
||||||
|
|
||||||
const results = yield* Effect.all(
|
|
||||||
Array.from({ length: 5 }, (_, index) =>
|
|
||||||
executeTool(registry, {
|
|
||||||
sessionID,
|
|
||||||
...toolIdentity,
|
|
||||||
call: {
|
|
||||||
type: "tool-call",
|
|
||||||
id: `call-concurrent-${index}`,
|
|
||||||
name: "websearch",
|
|
||||||
input: { query: `effect ${index}` },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(results.every((item) => item.status === "completed")).toBe(true)
|
|
||||||
expect(formRequests).toHaveLength(1)
|
|
||||||
expect(values.get("websearch:provider")).toBe("exa")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("persists the choice to disable web search", () =>
|
it.effect("persists the choice to disable web search", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
providerRequired = true
|
providerRequired = true
|
||||||
|
|||||||
@@ -44,12 +44,11 @@ export type SQLiteEffectSelectPrepare<
|
|||||||
TEffectHKT
|
TEffectHKT
|
||||||
>
|
>
|
||||||
|
|
||||||
// Explicit variance prevents comparisons from recursively scanning Drizzle's conditional select types.
|
|
||||||
export class SQLiteEffectSelectBuilder<
|
export class SQLiteEffectSelectBuilder<
|
||||||
out TSelection extends SelectedFields | undefined,
|
TSelection extends SelectedFields | undefined,
|
||||||
out TRunResult,
|
TRunResult,
|
||||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||||
out TBuilderMode extends "db" | "qb" = "db",
|
TBuilderMode extends "db" | "qb" = "db",
|
||||||
> {
|
> {
|
||||||
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
|
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
|
||||||
|
|
||||||
|
|||||||
@@ -303,11 +303,10 @@ export class SQLiteEffectPreparedQuery<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit variance prevents comparisons from recursively scanning the full Drizzle query-builder graph.
|
|
||||||
export abstract class SQLiteEffectSession<
|
export abstract class SQLiteEffectSession<
|
||||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||||
out TRunResult = unknown,
|
TRunResult = unknown,
|
||||||
out TRelations extends AnyRelations = EmptyRelations,
|
TRelations extends AnyRelations = EmptyRelations,
|
||||||
> {
|
> {
|
||||||
static readonly [entityKind]: string = "SQLiteEffectSession"
|
static readonly [entityKind]: string = "SQLiteEffectSession"
|
||||||
|
|
||||||
@@ -405,9 +404,9 @@ export abstract class SQLiteEffectSession<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export abstract class SQLiteEffectTransaction<
|
export abstract class SQLiteEffectTransaction<
|
||||||
out TEffectHKT extends QueryEffectHKTBase,
|
TEffectHKT extends QueryEffectHKTBase,
|
||||||
out TRunResult,
|
TRunResult,
|
||||||
out TRelations extends AnyRelations = EmptyRelations,
|
TRelations extends AnyRelations = EmptyRelations,
|
||||||
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
|
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
|
||||||
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
|
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Message, SystemPart } from "@opencode-ai/ai"
|
|||||||
import type { Agent } from "@opencode-ai/schema/agent"
|
import type { Agent } from "@opencode-ai/schema/agent"
|
||||||
import type { Model } from "@opencode-ai/schema/model"
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
import type { Session } from "@opencode-ai/schema/session"
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
import type { JsonSchema } from "effect"
|
import type { Effect, JsonSchema } from "effect"
|
||||||
import type { Hooks } from "./registration.js"
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
export interface SessionContext {
|
export interface SessionContext {
|
||||||
@@ -15,25 +15,23 @@ export interface SessionContext {
|
|||||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionHttpRequest {
|
export interface SessionHttp {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly agent: Agent.ID
|
readonly agent: Agent.ID
|
||||||
readonly model: Model.Ref
|
readonly model: Model.Ref
|
||||||
request: Request
|
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionHttpResponse {
|
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
||||||
readonly sessionID: Session.ID
|
|
||||||
readonly agent: Agent.ID
|
export type SessionHttpMiddleware = (
|
||||||
readonly model: Model.Ref
|
request: Request,
|
||||||
readonly request: Request
|
next: SessionHttpHandler,
|
||||||
response: Response
|
) => Effect.Effect<Response, Error>
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionHooks {
|
export interface SessionHooks {
|
||||||
readonly context: SessionContext
|
readonly context: SessionContext
|
||||||
readonly "http.request": SessionHttpRequest
|
readonly http: SessionHttp
|
||||||
readonly "http.response": SessionHttpResponse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionDomain = Pick<
|
export type SessionDomain = Pick<
|
||||||
|
|||||||
@@ -15,25 +15,23 @@ export interface SessionContext {
|
|||||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionHttpRequest {
|
export interface SessionHttp {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly agent: Agent.ID
|
readonly agent: Agent.ID
|
||||||
readonly model: Model.Ref
|
readonly model: Model.Ref
|
||||||
request: Request
|
readonly use: (middleware: SessionHttpMiddleware) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionHttpResponse {
|
export type SessionHttpHandler = (request: Request) => Promise<Response>
|
||||||
readonly sessionID: Session.ID
|
|
||||||
readonly agent: Agent.ID
|
export type SessionHttpMiddleware = (
|
||||||
readonly model: Model.Ref
|
request: Request,
|
||||||
readonly request: Request
|
next: SessionHttpHandler,
|
||||||
response: Response
|
) => Promise<Response> | Response
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionHooks {
|
export interface SessionHooks {
|
||||||
readonly context: SessionContext
|
readonly context: SessionContext
|
||||||
readonly "http.request": SessionHttpRequest
|
readonly http: SessionHttp
|
||||||
readonly "http.response": SessionHttpResponse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionDomain = Pick<
|
export type SessionDomain = Pick<
|
||||||
|
|||||||
+1539
-690
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
|||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
|
||||||
import { SessionPending } from "@opencode-ai/schema/session-pending"
|
import { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
@@ -151,8 +150,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||||||
payload: Schema.Struct({
|
payload: Schema.Struct({
|
||||||
id: Session.ID.pipe(Schema.optional),
|
id: Session.ID.pipe(Schema.optional),
|
||||||
title: Schema.String.pipe(Schema.optional),
|
title: Schema.String.pipe(Schema.optional),
|
||||||
agent: Agent.ID,
|
agent: Agent.ID.pipe(Schema.optional),
|
||||||
model: Model.Ref,
|
model: Model.Ref.pipe(Schema.optional),
|
||||||
location: Location.Ref.pipe(Schema.optional),
|
location: Location.Ref.pipe(Schema.optional),
|
||||||
}),
|
}),
|
||||||
success: Schema.Struct({ data: Session.Info }),
|
success: Schema.Struct({ data: Session.Info }),
|
||||||
@@ -160,37 +159,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "v2.session.create",
|
identifier: "v2.session.create",
|
||||||
summary: "Create session",
|
summary: "Create session",
|
||||||
description: "Create a session with an explicit agent and model at the requested location.",
|
description: "Create a session at the requested location.",
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.add(
|
|
||||||
HttpApiEndpoint.post("session.import", "/api/session/import", {
|
|
||||||
payload: Schema.Struct({
|
|
||||||
...SessionTransfer.Data.fields,
|
|
||||||
location: Location.Ref.pipe(Schema.optional),
|
|
||||||
}),
|
|
||||||
success: Schema.Struct({ data: Session.Info }),
|
|
||||||
error: ConflictError,
|
|
||||||
}).annotateMerge(
|
|
||||||
OpenApi.annotations({
|
|
||||||
identifier: "v2.session.import",
|
|
||||||
summary: "Import session",
|
|
||||||
description: "Import a projected session transcript at the requested location.",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.add(
|
|
||||||
HttpApiEndpoint.get("session.export", "/api/session/:sessionID/export", {
|
|
||||||
params: { sessionID: Session.ID },
|
|
||||||
query: Schema.Struct({ sanitize: BooleanFromString.pipe(Schema.optional) }),
|
|
||||||
success: Schema.Struct({ data: SessionTransfer.Data }),
|
|
||||||
error: [SessionNotFoundError, UnknownError],
|
|
||||||
}).annotateMerge(
|
|
||||||
OpenApi.annotations({
|
|
||||||
identifier: "v2.session.export",
|
|
||||||
summary: "Export session",
|
|
||||||
description: "Export a complete projected session transcript.",
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * as Config from "./config.js"
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { ephemeral, inventory } from "./event.js"
|
import { ephemeral, inventory } from "./event.js"
|
||||||
import { Permission } from "./permission.js"
|
import { Permission } from "./permission.js"
|
||||||
import { AbsolutePath, optional } from "./schema.js"
|
import { AbsolutePath } from "./schema.js"
|
||||||
import { ConfigAgent } from "./config/agent.js"
|
import { ConfigAgent } from "./config/agent.js"
|
||||||
import { ConfigMedia } from "./config/media.js"
|
import { ConfigMedia } from "./config/media.js"
|
||||||
import { ConfigCompaction } from "./config/compaction.js"
|
import { ConfigCompaction } from "./config/compaction.js"
|
||||||
@@ -22,94 +22,94 @@ import { ConfigWatcher } from "./config/watcher.js"
|
|||||||
import { ConfigWarming } from "./config/warming.js"
|
import { ConfigWarming } from "./config/warming.js"
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Info")({
|
export class Info extends Schema.Class<Info>("Config.Info")({
|
||||||
$schema: optional(Schema.String).annotate({
|
$schema: Schema.optional(Schema.String).annotate({
|
||||||
description: "JSON schema reference for configuration validation",
|
description: "JSON schema reference for configuration validation",
|
||||||
}),
|
}),
|
||||||
shell: Schema.String.pipe(optional).annotate({
|
shell: Schema.String.pipe(Schema.optional).annotate({
|
||||||
description: "Default shell to use for terminal and shell tool execution",
|
description: "Default shell to use for terminal and shell tool execution",
|
||||||
}),
|
}),
|
||||||
model: ConfigModel.Selection.pipe(optional).annotate({
|
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
|
||||||
description: "Default model to use when no session or agent model is selected",
|
description: "Default model to use when no session or agent model is selected",
|
||||||
}),
|
}),
|
||||||
default_agent: Schema.String.pipe(optional).annotate({
|
default_agent: Schema.String.pipe(Schema.optional).annotate({
|
||||||
description: "Default primary agent to use when no session agent is selected",
|
description: "Default primary agent to use when no session agent is selected",
|
||||||
}),
|
}),
|
||||||
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
|
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
|
||||||
.pipe(optional)
|
.pipe(Schema.optional)
|
||||||
.annotate({
|
.annotate({
|
||||||
description: "Automatically update or notify when a new version is available",
|
description: "Automatically update or notify when a new version is available",
|
||||||
}),
|
}),
|
||||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
|
||||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||||
}),
|
}),
|
||||||
enterprise: Schema.Struct({
|
enterprise: Schema.Struct({
|
||||||
url: Schema.String.pipe(optional),
|
url: Schema.String.pipe(Schema.optional),
|
||||||
})
|
})
|
||||||
.pipe(optional)
|
.pipe(Schema.optional)
|
||||||
.annotate({
|
.annotate({
|
||||||
description: "Enterprise sharing service configuration",
|
description: "Enterprise sharing service configuration",
|
||||||
}),
|
}),
|
||||||
username: Schema.String.pipe(optional).annotate({
|
username: Schema.String.pipe(Schema.optional).annotate({
|
||||||
description: "Username displayed in conversations and used for telemetry identity",
|
description: "Username displayed in conversations and used for telemetry identity",
|
||||||
}),
|
}),
|
||||||
permissions: Permission.Ruleset.pipe(optional).annotate({
|
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
|
||||||
description: "Ordered tool permission rules applied to agent tool use",
|
description: "Ordered tool permission rules applied to agent tool use",
|
||||||
}),
|
}),
|
||||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(optional).annotate({
|
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
||||||
description: "Named built-in agent overrides and custom agent definitions",
|
description: "Named built-in agent overrides and custom agent definitions",
|
||||||
}),
|
}),
|
||||||
snapshots: Schema.Boolean.pipe(optional).annotate({
|
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||||
description: "Enable snapshots used for undo and revert behavior",
|
description: "Enable snapshots used for undo and revert behavior",
|
||||||
}),
|
}),
|
||||||
watcher: ConfigWatcher.Info.pipe(optional).annotate({
|
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
|
||||||
description: "Filesystem watcher configuration",
|
description: "Filesystem watcher configuration",
|
||||||
}),
|
}),
|
||||||
formatter: ConfigFormatter.Info.pipe(optional).annotate({
|
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
|
||||||
description: "Enable built-in formatters or configure formatter overrides",
|
description: "Enable built-in formatters or configure formatter overrides",
|
||||||
}),
|
}),
|
||||||
lsp: ConfigLSP.Info.pipe(optional).annotate({
|
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
||||||
description: "Enable built-in language servers or configure server overrides",
|
description: "Enable built-in language servers or configure server overrides",
|
||||||
}),
|
}),
|
||||||
media: ConfigMedia.Info.pipe(optional).annotate({
|
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
||||||
description: "Media processing configuration",
|
description: "Media processing configuration",
|
||||||
}),
|
}),
|
||||||
tool_output: ConfigToolOutput.Info.pipe(optional).annotate({
|
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||||
description: "Tool output truncation thresholds",
|
description: "Tool output truncation thresholds",
|
||||||
}),
|
}),
|
||||||
mcp: ConfigMCP.Info.pipe(optional).annotate({
|
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
|
||||||
description: "MCP server configuration",
|
description: "MCP server configuration",
|
||||||
}),
|
}),
|
||||||
compaction: ConfigCompaction.Info.pipe(optional).annotate({
|
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
|
||||||
description: "Conversation compaction behavior",
|
description: "Conversation compaction behavior",
|
||||||
}),
|
}),
|
||||||
skills: Schema.String.pipe(Schema.Array, optional).annotate({
|
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||||
description: "Additional paths or URLs to discover skills from",
|
description: "Additional paths or URLs to discover skills from",
|
||||||
}),
|
}),
|
||||||
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(optional).annotate({
|
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
|
||||||
description: "Named slash command definitions",
|
description: "Named slash command definitions",
|
||||||
}),
|
}),
|
||||||
instructions: Schema.String.pipe(Schema.Array, optional).annotate({
|
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||||
description: "Additional paths or URLs supplying ambient instructions",
|
description: "Additional paths or URLs supplying ambient instructions",
|
||||||
}),
|
}),
|
||||||
references: ConfigReference.Info.pipe(optional).annotate({
|
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
||||||
description: "Named local directories or Git repositories available as external context",
|
description: "Named local directories or Git repositories available as external context",
|
||||||
}),
|
}),
|
||||||
websearch: ConfigWebSearch.Info.pipe(optional).annotate({
|
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
|
||||||
description: "Web search provider selection",
|
description: "Web search provider selection",
|
||||||
}),
|
}),
|
||||||
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
|
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||||
description: "Ordered plugin enablement directives and external package declarations",
|
description: "Ordered plugin enablement directives and external package declarations",
|
||||||
}),
|
}),
|
||||||
warming: ConfigWarming.Warming.pipe(optional).annotate({
|
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
|
||||||
description: "Keep recently active sessions warm with transient model requests (default: false)",
|
description: "Keep recently active sessions warm with transient model requests (default: false)",
|
||||||
}),
|
}),
|
||||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional),
|
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||||
experimental: ConfigExperimental.Info.pipe(optional),
|
experimental: ConfigExperimental.Info.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class Document extends Schema.Class<Document>("Config.Document")({
|
export class Document extends Schema.Class<Document>("Config.Document")({
|
||||||
type: Schema.Literal("document"),
|
type: Schema.Literal("document"),
|
||||||
path: Schema.String.pipe(optional),
|
path: Schema.String.pipe(Schema.optional),
|
||||||
info: Info,
|
info: Info,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ export * as ConfigAgent from "./agent.js"
|
|||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { Permission } from "../permission.js"
|
import { Permission } from "../permission.js"
|
||||||
import { optional, PositiveInt } from "../schema.js"
|
import { PositiveInt } from "../schema.js"
|
||||||
import { ConfigModel } from "./model.js"
|
import { ConfigModel } from "./model.js"
|
||||||
import { ConfigProvider } from "./provider.js"
|
import { ConfigProvider } from "./provider.js"
|
||||||
|
|
||||||
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Agent")({
|
export class Info extends Schema.Class<Info>("Config.Agent")({
|
||||||
model: ConfigModel.Selection.pipe(optional),
|
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||||
request: ConfigProvider.Request.pipe(optional),
|
request: ConfigProvider.Request.pipe(Schema.optional),
|
||||||
system: Schema.String.pipe(optional),
|
system: Schema.String.pipe(Schema.optional),
|
||||||
description: Schema.String.pipe(optional),
|
description: Schema.String.pipe(Schema.optional),
|
||||||
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(optional),
|
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
|
||||||
hidden: Schema.Boolean.pipe(optional),
|
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||||
color: Color.pipe(optional),
|
color: Color.pipe(Schema.optional),
|
||||||
steps: PositiveInt.pipe(optional),
|
steps: PositiveInt.pipe(Schema.optional),
|
||||||
disabled: Schema.Boolean.pipe(optional),
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
permissions: Permission.Ruleset.pipe(optional),
|
permissions: Permission.Ruleset.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
export * as ConfigCommand from "./command.js"
|
export * as ConfigCommand from "./command.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional } from "../schema.js"
|
|
||||||
import { ConfigModel } from "./model.js"
|
import { ConfigModel } from "./model.js"
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Command")({
|
export class Info extends Schema.Class<Info>("Config.Command")({
|
||||||
template: Schema.String,
|
template: Schema.String,
|
||||||
description: Schema.String.pipe(optional),
|
description: Schema.String.pipe(Schema.optional),
|
||||||
agent: Schema.String.pipe(optional),
|
agent: Schema.String.pipe(Schema.optional),
|
||||||
model: ConfigModel.Selection.pipe(optional),
|
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||||
subtask: Schema.Boolean.pipe(optional),
|
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
export * as ConfigCompaction from "./compaction.js"
|
export * as ConfigCompaction from "./compaction.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { NonNegativeInt, optional } from "../schema.js"
|
import { NonNegativeInt } from "../schema.js"
|
||||||
|
|
||||||
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
|
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
|
||||||
tokens: NonNegativeInt.pipe(optional),
|
tokens: NonNegativeInt.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Compaction")({
|
export class Info extends Schema.Class<Info>("Config.Compaction")({
|
||||||
auto: Schema.Boolean.pipe(optional),
|
auto: Schema.Boolean.pipe(Schema.optional),
|
||||||
keep: Keep.pipe(optional),
|
keep: Keep.pipe(Schema.optional),
|
||||||
buffer: NonNegativeInt.pipe(optional),
|
buffer: NonNegativeInt.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
export * as ConfigExperimental from "./experimental.js"
|
export * as ConfigExperimental from "./experimental.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { NonNegativeInt, optional } from "../schema.js"
|
import { NonNegativeInt } from "../schema.js"
|
||||||
import { ConfigPolicy } from "./policy.js"
|
import { ConfigPolicy } from "./policy.js"
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
||||||
subagent_depth: NonNegativeInt.pipe(optional).annotate({
|
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
|
||||||
description: "Maximum subagent nesting depth. Defaults to 1.",
|
description: "Maximum subagent nesting depth. Defaults to 1.",
|
||||||
}),
|
}),
|
||||||
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
|
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
|
||||||
description: "Ordered policies controlling access to configured resources",
|
description: "Ordered policies controlling access to configured resources",
|
||||||
}),
|
}),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
export * as ConfigFormatter from "./formatter.js"
|
export * as ConfigFormatter from "./formatter.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional } from "../schema.js"
|
|
||||||
|
|
||||||
export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({
|
export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({
|
||||||
disabled: Schema.Boolean.pipe(optional),
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
command: Schema.String.pipe(Schema.Array, optional),
|
command: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||||
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
extensions: Schema.String.pipe(Schema.Array, optional),
|
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
|
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export * as ConfigLSP from "./lsp.js"
|
export * as ConfigLSP from "./lsp.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional } from "../schema.js"
|
|
||||||
|
|
||||||
export const Disabled = Schema.Struct({
|
export const Disabled = Schema.Struct({
|
||||||
disabled: Schema.Literal(true),
|
disabled: Schema.Literal(true),
|
||||||
@@ -9,10 +8,10 @@ export const Disabled = Schema.Struct({
|
|||||||
|
|
||||||
export class Server extends Schema.Class<Server>("Config.LSP.Server")({
|
export class Server extends Schema.Class<Server>("Config.LSP.Server")({
|
||||||
command: Schema.String.pipe(Schema.Array),
|
command: Schema.String.pipe(Schema.Array),
|
||||||
extensions: Schema.String.pipe(Schema.Array, optional),
|
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||||
disabled: Schema.Boolean.pipe(optional),
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
env: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const Entry = Schema.Union([Disabled, Server])
|
export const Entry = Schema.Union([Disabled, Server])
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export * as ConfigMCP from "./mcp.js"
|
|||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { Mcp } from "../mcp.js"
|
import { Mcp } from "../mcp.js"
|
||||||
import { optional } from "../schema.js"
|
|
||||||
|
|
||||||
export const Timeout = Mcp.TimeoutConfig
|
export const Timeout = Mcp.TimeoutConfig
|
||||||
export type Timeout = Mcp.TimeoutConfig
|
export type Timeout = Mcp.TimeoutConfig
|
||||||
@@ -15,6 +14,6 @@ export type Remote = Mcp.RemoteConfig
|
|||||||
export const Server = Mcp.ServerConfig
|
export const Server = Mcp.ServerConfig
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.MCP")({
|
export class Info extends Schema.Class<Info>("Config.MCP")({
|
||||||
timeout: Timeout.pipe(optional),
|
timeout: Timeout.pipe(Schema.optional),
|
||||||
servers: Schema.Record(Schema.String, Server).pipe(optional),
|
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
export * as ConfigMedia from "./media.js"
|
export * as ConfigMedia from "./media.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional, PositiveInt } from "../schema.js"
|
import { PositiveInt } from "../schema.js"
|
||||||
|
|
||||||
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
||||||
auto_resize: Schema.Boolean.pipe(optional),
|
auto_resize: Schema.Boolean.pipe(Schema.optional),
|
||||||
max_width: PositiveInt.pipe(optional),
|
max_width: PositiveInt.pipe(Schema.optional),
|
||||||
max_height: PositiveInt.pipe(optional),
|
max_height: PositiveInt.pipe(Schema.optional),
|
||||||
max_base64_bytes: PositiveInt.pipe(optional),
|
max_base64_bytes: PositiveInt.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Media")({
|
export class Info extends Schema.Class<Info>("Config.Media")({
|
||||||
image: Image.pipe(optional),
|
image: Image.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export * as ConfigModel from "./model.js"
|
|||||||
import { Schema, SchemaGetter } from "effect"
|
import { Schema, SchemaGetter } from "effect"
|
||||||
import { Model } from "../model.js"
|
import { Model } from "../model.js"
|
||||||
import { Provider } from "../provider.js"
|
import { Provider } from "../provider.js"
|
||||||
import { optional } from "../schema.js"
|
|
||||||
|
|
||||||
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
|
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
|
||||||
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
|
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
|
||||||
@@ -12,7 +11,7 @@ const VariantID = Model.VariantID.check(Schema.isPattern(/^[^#]+$/))
|
|||||||
const Explicit = Schema.Struct({
|
const Explicit = Schema.Struct({
|
||||||
providerID: ProviderID,
|
providerID: ProviderID,
|
||||||
model: ModelID,
|
model: ModelID,
|
||||||
variant: VariantID.pipe(optional),
|
variant: VariantID.pipe(Schema.optional),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
|
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
export * as ConfigPlugin from "./plugin.js"
|
export * as ConfigPlugin from "./plugin.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional } from "../schema.js"
|
|
||||||
|
|
||||||
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
|
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
|
||||||
package: Schema.String,
|
package: Schema.String,
|
||||||
options: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const Plugin = Schema.Union([Schema.String, Entry])
|
export const Plugin = Schema.Union([Schema.String, Entry])
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ export * as ConfigProvider from "./provider.js"
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { Money } from "../money.js"
|
import { Money } from "../money.js"
|
||||||
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
|
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
|
||||||
import { optional } from "../schema.js"
|
|
||||||
|
|
||||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||||
|
|
||||||
export const Overlays = {
|
export const Overlays = {
|
||||||
settings: JsonRecord.pipe(optional),
|
settings: JsonRecord.pipe(Schema.optional),
|
||||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
body: JsonRecord.pipe(optional),
|
body: JsonRecord.pipe(Schema.optional),
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
|
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
|
||||||
@@ -19,47 +18,47 @@ export class Request extends Schema.Class<Request>("Config.Provider.Request")({
|
|||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
|
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
|
||||||
read: Money.USDPerMillionTokens.pipe(optional),
|
read: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||||
write: Money.USDPerMillionTokens.pipe(optional),
|
write: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
|
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
|
||||||
tier: Schema.Struct({
|
tier: Schema.Struct({
|
||||||
type: Schema.Literal("context"),
|
type: Schema.Literal("context"),
|
||||||
size: Schema.Int,
|
size: Schema.Int,
|
||||||
}).pipe(optional),
|
}).pipe(Schema.optional),
|
||||||
input: Money.USDPerMillionTokens,
|
input: Money.USDPerMillionTokens,
|
||||||
output: Money.USDPerMillionTokens,
|
output: Money.USDPerMillionTokens,
|
||||||
cache: Cache.pipe(optional),
|
cache: Cache.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
|
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
|
||||||
context: Schema.Int.pipe(optional),
|
context: Schema.Int.pipe(Schema.optional),
|
||||||
input: Schema.Int.pipe(optional),
|
input: Schema.Int.pipe(Schema.optional),
|
||||||
output: Schema.Int.pipe(optional),
|
output: Schema.Int.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
class Model extends Schema.Class<Model>("Config.Model")({
|
class Model extends Schema.Class<Model>("Config.Model")({
|
||||||
modelID: ID.pipe(optional),
|
modelID: ID.pipe(Schema.optional),
|
||||||
family: Family.pipe(optional),
|
family: Family.pipe(Schema.optional),
|
||||||
name: Schema.String.pipe(optional),
|
name: Schema.String.pipe(Schema.optional),
|
||||||
compatibility: Compatibility.pipe(optional),
|
compatibility: Compatibility.pipe(Schema.optional),
|
||||||
package: Schema.String.pipe(optional),
|
package: Schema.String.pipe(Schema.optional),
|
||||||
...Overlays,
|
...Overlays,
|
||||||
capabilities: Capabilities.pipe(optional),
|
capabilities: Capabilities.pipe(Schema.optional),
|
||||||
variants: Schema.Struct({
|
variants: Schema.Struct({
|
||||||
id: VariantID,
|
id: VariantID,
|
||||||
...Overlays,
|
...Overlays,
|
||||||
}).pipe(Schema.Array, optional),
|
}).pipe(Schema.Array, Schema.optional),
|
||||||
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
|
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
|
||||||
disabled: Schema.Boolean.pipe(optional),
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
limit: Limit.pipe(optional),
|
limit: Limit.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Provider")({
|
export class Info extends Schema.Class<Info>("Config.Provider")({
|
||||||
name: Schema.String.pipe(optional),
|
name: Schema.String.pipe(Schema.optional),
|
||||||
env: Schema.String.pipe(Schema.Array, optional),
|
env: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||||
package: Schema.String.pipe(optional),
|
package: Schema.String.pipe(Schema.optional),
|
||||||
...Overlays,
|
...Overlays,
|
||||||
models: Schema.Record(Schema.String, Model).pipe(optional),
|
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
export * as ConfigReference from "./reference.js"
|
export * as ConfigReference from "./reference.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional } from "../schema.js"
|
|
||||||
|
|
||||||
export class Git extends Schema.Class<Git>("Config.Reference.Git")({
|
export class Git extends Schema.Class<Git>("Config.Reference.Git")({
|
||||||
repository: Schema.String,
|
repository: Schema.String,
|
||||||
branch: Schema.String.pipe(optional),
|
branch: Schema.String.pipe(Schema.optional),
|
||||||
description: Schema.String.pipe(optional),
|
description: Schema.String.pipe(Schema.optional),
|
||||||
hidden: Schema.Boolean.pipe(optional),
|
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
|
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
description: Schema.String.pipe(optional),
|
description: Schema.String.pipe(Schema.optional),
|
||||||
hidden: Schema.Boolean.pipe(optional),
|
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const Entry = Schema.Union([Schema.String, Git, Local])
|
export const Entry = Schema.Union([Schema.String, Git, Local])
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export * as ConfigToolOutput from "./tool-output.js"
|
export * as ConfigToolOutput from "./tool-output.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional, PositiveInt } from "../schema.js"
|
import { PositiveInt } from "../schema.js"
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
|
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
|
||||||
max_lines: PositiveInt.pipe(optional),
|
max_lines: PositiveInt.pipe(Schema.optional),
|
||||||
max_bytes: PositiveInt.pipe(optional),
|
max_bytes: PositiveInt.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
export * as ConfigWarming from "./warming.js"
|
export * as ConfigWarming from "./warming.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional } from "../schema.js"
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Warming")({
|
export class Info extends Schema.Class<Info>("Config.Warming")({
|
||||||
prompt: Schema.String.pipe(optional).annotate({
|
prompt: Schema.String.pipe(Schema.optional).annotate({
|
||||||
description: "Prompt sent for keep-alive requests",
|
description: "Prompt sent for keep-alive requests",
|
||||||
}),
|
}),
|
||||||
interval: Schema.DurationFromString.pipe(optional).annotate({
|
interval: Schema.DurationFromString.pipe(Schema.optional).annotate({
|
||||||
description: 'Idle time between keep-alive requests (default: "4 minutes")',
|
description: 'Idle time between keep-alive requests (default: "4 minutes")',
|
||||||
}),
|
}),
|
||||||
duration: Schema.DurationFromString.pipe(optional).annotate({
|
duration: Schema.DurationFromString.pipe(Schema.optional).annotate({
|
||||||
description: 'Time after the last active request to keep a session warm (default: "30 minutes")',
|
description: 'Time after the last active request to keep a session warm (default: "30 minutes")',
|
||||||
}),
|
}),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export * as ConfigWatcher from "./watcher.js"
|
export * as ConfigWatcher from "./watcher.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional } from "../schema.js"
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Watcher")({
|
export class Info extends Schema.Class<Info>("Config.Watcher")({
|
||||||
ignore: Schema.String.pipe(Schema.Array, optional),
|
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ const InfoBase = {
|
|||||||
// on non-session owners anywhere else.
|
// on non-session owners anywhere else.
|
||||||
sessionID: Schema.String,
|
sessionID: Schema.String,
|
||||||
title: Schema.String,
|
title: Schema.String,
|
||||||
|
coalesce: Schema.String.pipe(optional).annotate({
|
||||||
|
description: "Client-local key for displaying equivalent pending forms once and broadcasting one response.",
|
||||||
|
}),
|
||||||
metadata: Metadata.pipe(optional),
|
metadata: Metadata.pipe(optional),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export { Vcs } from "./vcs.js"
|
|||||||
export { SessionPending } from "./session-pending.js"
|
export { SessionPending } from "./session-pending.js"
|
||||||
export { SessionError } from "./session-error.js"
|
export { SessionError } from "./session-error.js"
|
||||||
export { SessionMessage } from "./session-message.js"
|
export { SessionMessage } from "./session-message.js"
|
||||||
export { SessionTransfer } from "./session-transfer.js"
|
|
||||||
export { Snapshot } from "./snapshot.js"
|
export { Snapshot } from "./snapshot.js"
|
||||||
export { Shell } from "./shell.js"
|
export { Shell } from "./shell.js"
|
||||||
export { Skill } from "./skill.js"
|
export { Skill } from "./skill.js"
|
||||||
|
|||||||
+23
-19
@@ -5,13 +5,13 @@ import { optional, PositiveInt } from "./schema.js"
|
|||||||
import { IntegrationID } from "./integration-id.js"
|
import { IntegrationID } from "./integration-id.js"
|
||||||
|
|
||||||
export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfig")({
|
export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfig")({
|
||||||
startup: PositiveInt.pipe(optional).annotate({
|
startup: PositiveInt.pipe(Schema.optional).annotate({
|
||||||
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
|
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
|
||||||
}),
|
}),
|
||||||
catalog: PositiveInt.pipe(optional).annotate({
|
catalog: PositiveInt.pipe(Schema.optional).annotate({
|
||||||
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
|
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
|
||||||
}),
|
}),
|
||||||
execution: PositiveInt.pipe(optional).annotate({
|
execution: PositiveInt.pipe(Schema.optional).annotate({
|
||||||
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
|
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
|
||||||
}),
|
}),
|
||||||
}) {}
|
}) {}
|
||||||
@@ -19,35 +19,35 @@ export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfi
|
|||||||
export class LocalConfig extends Schema.Class<LocalConfig>("Mcp.LocalConfig")({
|
export class LocalConfig extends Schema.Class<LocalConfig>("Mcp.LocalConfig")({
|
||||||
type: Schema.Literal("local"),
|
type: Schema.Literal("local"),
|
||||||
command: Schema.String.pipe(Schema.Array),
|
command: Schema.String.pipe(Schema.Array),
|
||||||
cwd: Schema.String.pipe(optional).annotate({
|
cwd: Schema.String.pipe(Schema.optional).annotate({
|
||||||
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
|
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
|
||||||
}),
|
}),
|
||||||
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
disabled: Schema.Boolean.pipe(optional),
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
codemode: Schema.Boolean.pipe(optional).annotate({
|
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||||
description: "Expose this server's tools through Code Mode. Defaults to true.",
|
description: "Expose this server's tools through Code Mode. Defaults to true.",
|
||||||
}),
|
}),
|
||||||
timeout: TimeoutConfig.pipe(optional),
|
timeout: TimeoutConfig.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
|
export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
|
||||||
client_id: Schema.String.pipe(optional),
|
client_id: Schema.String.pipe(Schema.optional),
|
||||||
client_secret: Schema.String.pipe(optional),
|
client_secret: Schema.String.pipe(Schema.optional),
|
||||||
scope: Schema.String.pipe(optional),
|
scope: Schema.String.pipe(Schema.optional),
|
||||||
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional),
|
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
|
||||||
redirect_uri: Schema.String.pipe(optional),
|
redirect_uri: Schema.String.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
|
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
|
||||||
type: Schema.Literal("remote"),
|
type: Schema.Literal("remote"),
|
||||||
url: Schema.String,
|
url: Schema.String,
|
||||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(optional),
|
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(Schema.optional),
|
||||||
disabled: Schema.Boolean.pipe(optional),
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
codemode: Schema.Boolean.pipe(optional).annotate({
|
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||||
description: "Expose this server's tools through Code Mode. Defaults to true.",
|
description: "Expose this server's tools through Code Mode. Defaults to true.",
|
||||||
}),
|
}),
|
||||||
timeout: TimeoutConfig.pipe(optional),
|
timeout: TimeoutConfig.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type"))
|
export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type"))
|
||||||
@@ -68,9 +68,13 @@ const Failed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.S
|
|||||||
const NeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({
|
const NeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({
|
||||||
identifier: "Mcp.Status.NeedsAuth",
|
identifier: "Mcp.Status.NeedsAuth",
|
||||||
})
|
})
|
||||||
|
const NeedsClientRegistration = Schema.Struct({
|
||||||
|
status: Schema.Literal("needs_client_registration"),
|
||||||
|
error: Schema.String,
|
||||||
|
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
|
||||||
|
|
||||||
export type Status = typeof Status.Type
|
export type Status = typeof Status.Type
|
||||||
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth]).pipe(
|
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
|
||||||
Schema.toTaggedUnion("status"),
|
Schema.toTaggedUnion("status"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,9 @@ export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
|
|||||||
Schema.String,
|
Schema.String,
|
||||||
]).annotate({ identifier: "Model.ReasoningField" })
|
]).annotate({ identifier: "Model.ReasoningField" })
|
||||||
|
|
||||||
export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tokens"]).annotate({
|
|
||||||
identifier: "Model.MaxTokensField",
|
|
||||||
})
|
|
||||||
export type MaxTokensField = typeof MaxTokensField.Type
|
|
||||||
|
|
||||||
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
|
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
|
||||||
export const Compatibility = Schema.Struct({
|
export const Compatibility = Schema.Struct({
|
||||||
reasoningField: ReasoningField.pipe(optional),
|
reasoningField: ReasoningField.pipe(optional),
|
||||||
maxTokensField: MaxTokensField.pipe(optional),
|
|
||||||
requireFinishReason: Schema.Boolean.pipe(optional),
|
|
||||||
}).annotate({ identifier: "Model.Compatibility" })
|
}).annotate({ identifier: "Model.Compatibility" })
|
||||||
|
|
||||||
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
|
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
export * as SessionTransfer from "./session-transfer.js"
|
|
||||||
|
|
||||||
import { Schema } from "effect"
|
|
||||||
import { Session } from "./session.js"
|
|
||||||
import { SessionMessage } from "./session-message.js"
|
|
||||||
|
|
||||||
export interface Data extends Schema.Schema.Type<typeof Data> {}
|
|
||||||
export const Data = Schema.Struct({
|
|
||||||
info: Session.Info,
|
|
||||||
messages: Schema.Array(SessionMessage.Info),
|
|
||||||
}).annotate({ identifier: "SessionTransfer.Data" })
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { Config } from "../src/config.js"
|
import { Config } from "../src/config.js"
|
||||||
import { ConfigAgent } from "../src/config/agent.js"
|
|
||||||
import { ConfigMCP } from "../src/config/mcp.js"
|
|
||||||
import { ConfigProvider } from "../src/config/provider.js"
|
|
||||||
import { Mcp } from "../src/mcp.js"
|
|
||||||
import { AbsolutePath } from "../src/schema.js"
|
import { AbsolutePath } from "../src/schema.js"
|
||||||
|
|
||||||
describe("Config.Entry", () => {
|
describe("Config.Entry", () => {
|
||||||
@@ -33,14 +29,7 @@ describe("Config.Entry", () => {
|
|||||||
expect(decoded).toEqual(entries)
|
expect(decoded).toEqual(entries)
|
||||||
expect(decoded[0]).toBeInstanceOf(Config.Document)
|
expect(decoded[0]).toBeInstanceOf(Config.Document)
|
||||||
expect(decoded[1]).not.toHaveProperty("path")
|
expect(decoded[1]).not.toHaveProperty("path")
|
||||||
expect(decoded.map((entry) => entry.type)).toEqual([
|
expect(decoded.map((entry) => entry.type)).toEqual(["document", "document", "directory", "file", "agents", "claude"])
|
||||||
"document",
|
|
||||||
"document",
|
|
||||||
"directory",
|
|
||||||
"file",
|
|
||||||
"agents",
|
|
||||||
"claude",
|
|
||||||
])
|
|
||||||
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
|
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
|
||||||
{ action: "shell", resource: "*", effect: "ask" },
|
{ action: "shell", resource: "*", effect: "ask" },
|
||||||
{ action: "shell", resource: "git status", effect: "allow" },
|
{ action: "shell", resource: "git status", effect: "allow" },
|
||||||
@@ -50,39 +39,4 @@ describe("Config.Entry", () => {
|
|||||||
test("has a stable public identifier", () => {
|
test("has a stable public identifier", () => {
|
||||||
expect(Config.Entry.ast.annotations?.identifier).toBe("Config.Entry")
|
expect(Config.Entry.ast.annotations?.identifier).toBe("Config.Entry")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("omits undefined optional properties while encoding", () => {
|
|
||||||
const entry = new Config.Document({
|
|
||||||
type: "document",
|
|
||||||
path: undefined,
|
|
||||||
info: new Config.Info({
|
|
||||||
default_agent: undefined,
|
|
||||||
agents: { reviewer: new ConfigAgent.Info({ description: undefined }) },
|
|
||||||
mcp: new ConfigMCP.Info({
|
|
||||||
timeout: undefined,
|
|
||||||
servers: {
|
|
||||||
docs: new Mcp.RemoteConfig({
|
|
||||||
type: "remote",
|
|
||||||
url: "https://example.com/mcp",
|
|
||||||
headers: undefined,
|
|
||||||
oauth: new Mcp.OAuthConfig({ client_id: undefined }),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
providers: { custom: new ConfigProvider.Info({ headers: undefined }) },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const encoded = Schema.encodeSync(Config.Entry)(entry)
|
|
||||||
if (encoded.type !== "document") throw new Error("Expected a config document")
|
|
||||||
|
|
||||||
expect(encoded).not.toHaveProperty("path")
|
|
||||||
expect(encoded.info).not.toHaveProperty("default_agent")
|
|
||||||
expect(encoded.info.agents?.reviewer).not.toHaveProperty("description")
|
|
||||||
expect(encoded.info.mcp).not.toHaveProperty("timeout")
|
|
||||||
const docs = encoded.info.mcp?.servers?.docs
|
|
||||||
if (docs?.type !== "remote" || docs.oauth === false) throw new Error("Expected a remote MCP server")
|
|
||||||
expect(docs).not.toHaveProperty("headers")
|
|
||||||
expect(docs.oauth).not.toHaveProperty("client_id")
|
|
||||||
expect(encoded.info.providers?.custom).not.toHaveProperty("headers")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,22 +30,3 @@ describe("Model.ReasoningField", () => {
|
|||||||
expect(decode(field)).toBe(field)
|
expect(decode(field)).toBe(field)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Model.Compatibility", () => {
|
|
||||||
test("decodes model compatibility overrides", () => {
|
|
||||||
const decode = Schema.decodeUnknownSync(Model.Compatibility)
|
|
||||||
|
|
||||||
expect(decode({})).toEqual({})
|
|
||||||
expect(
|
|
||||||
decode({
|
|
||||||
reasoningField: "vendor_reasoning",
|
|
||||||
maxTokensField: "max_completion_tokens",
|
|
||||||
requireFinishReason: false,
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
reasoningField: "vendor_reasoning",
|
|
||||||
maxTokensField: "max_completion_tokens",
|
|
||||||
requireFinishReason: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Session } from "@opencode-ai/core/session"
|
import { Session } from "@opencode-ai/core/session"
|
||||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
|
||||||
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
|
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
|
||||||
import { DateTime, Effect, Stream } from "effect"
|
import { DateTime, Effect, Stream } from "effect"
|
||||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||||
@@ -25,7 +24,6 @@ const DefaultSessionsLimit = 50
|
|||||||
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const transfer = yield* SessionTransfer.Service
|
|
||||||
|
|
||||||
return handlers
|
return handlers
|
||||||
.handle(
|
.handle(
|
||||||
@@ -88,56 +86,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.handle(
|
|
||||||
"session.import",
|
|
||||||
Effect.fn(function* (ctx) {
|
|
||||||
return {
|
|
||||||
data: yield* transfer
|
|
||||||
.import({
|
|
||||||
data: { info: ctx.payload.info, messages: ctx.payload.messages },
|
|
||||||
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.catchTag(
|
|
||||||
"SessionTransfer.ImportConflictError",
|
|
||||||
(error) =>
|
|
||||||
new ConflictError({
|
|
||||||
message: `Session already exists: ${error.sessionID}`,
|
|
||||||
resource: error.sessionID,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.handle(
|
|
||||||
"session.export",
|
|
||||||
Effect.fn(function* (ctx) {
|
|
||||||
return {
|
|
||||||
data: yield* transfer.export({ sessionID: ctx.params.sessionID, sanitize: ctx.query.sanitize }).pipe(
|
|
||||||
Effect.catchTag(
|
|
||||||
"Session.NotFoundError",
|
|
||||||
(error) =>
|
|
||||||
new SessionNotFoundError({
|
|
||||||
sessionID: error.sessionID,
|
|
||||||
message: `Session not found: ${error.sessionID}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.catchTag("Session.MessageDecodeError", (error) => {
|
|
||||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
|
||||||
return Effect.logError("failed to decode session message").pipe(
|
|
||||||
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
|
|
||||||
Effect.andThen(
|
|
||||||
Effect.fail(
|
|
||||||
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.handle(
|
.handle(
|
||||||
"session.active",
|
"session.active",
|
||||||
Effect.fn(function* () {
|
Effect.fn(function* () {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
|||||||
import { Pty } from "@opencode-ai/core/pty"
|
import { Pty } from "@opencode-ai/core/pty"
|
||||||
import { Project } from "@opencode-ai/core/project"
|
import { Project } from "@opencode-ai/core/project"
|
||||||
import { Session } from "@opencode-ai/core/session"
|
import { Session } from "@opencode-ai/core/session"
|
||||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
|
||||||
import { Shell } from "@opencode-ai/core/shell"
|
import { Shell } from "@opencode-ai/core/shell"
|
||||||
import { Job } from "@opencode-ai/core/job"
|
import { Job } from "@opencode-ai/core/job"
|
||||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||||
@@ -52,7 +51,6 @@ const applicationServices = LayerNode.group([
|
|||||||
Job.node,
|
Job.node,
|
||||||
Project.node,
|
Project.node,
|
||||||
Session.node,
|
Session.node,
|
||||||
SessionTransfer.node,
|
|
||||||
PluginRuntime.providerNode,
|
PluginRuntime.providerNode,
|
||||||
SdkPlugins.node,
|
SdkPlugins.node,
|
||||||
PermissionSaved.node,
|
PermissionSaved.node,
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ it.live("returns ordered config entries for the requested directory", () =>
|
|||||||
const global = path.join(tmp.path, "global")
|
const global = path.join(tmp.path, "global")
|
||||||
const project = path.join(tmp.path, "project")
|
const project = path.join(tmp.path, "project")
|
||||||
const config = path.join(project, "opencode.json")
|
const config = path.join(project, "opencode.json")
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.promise(() => Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]))
|
||||||
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
|
|
||||||
)
|
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.promise(() =>
|
||||||
fs.writeFile(
|
fs.writeFile(
|
||||||
config,
|
config,
|
||||||
@@ -27,7 +25,6 @@ it.live("returns ordered config entries for the requested directory", () =>
|
|||||||
{ action: "shell", resource: "*", effect: "ask" },
|
{ action: "shell", resource: "*", effect: "ask" },
|
||||||
{ action: "shell", resource: "git status", effect: "allow" },
|
{ action: "shell", resource: "git status", effect: "allow" },
|
||||||
],
|
],
|
||||||
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -45,8 +42,9 @@ it.live("returns ordered config entries for the requested directory", () =>
|
|||||||
const response = yield* Effect.promise(() =>
|
const response = yield* Effect.promise(() =>
|
||||||
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
|
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
|
||||||
)
|
)
|
||||||
const body: unknown = yield* Effect.promise(() => response.json())
|
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(
|
||||||
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
|
yield* Effect.promise(() => response.json()),
|
||||||
|
)
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(Array.isArray(entries)).toBe(true)
|
expect(Array.isArray(entries)).toBe(true)
|
||||||
@@ -58,21 +56,7 @@ it.live("returns ordered config entries for the requested directory", () =>
|
|||||||
{ action: "shell", resource: "git status", effect: "allow" },
|
{ action: "shell", resource: "git status", effect: "allow" },
|
||||||
])
|
])
|
||||||
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true)
|
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true)
|
||||||
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
|
|
||||||
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
|
|
||||||
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
|
|
||||||
expect(raw["info"]).not.toHaveProperty("default_agent")
|
|
||||||
expect(raw["info"]).not.toHaveProperty("model")
|
|
||||||
const mcp = raw["info"]["mcp"]
|
|
||||||
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
|
|
||||||
throw new Error("Expected an MCP server config")
|
|
||||||
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
|
|
||||||
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
|
|
||||||
}),
|
}),
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
||||||
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
||||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||||
const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
|
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
|
||||||
const tabsVisible = () =>
|
const tabsVisible = () =>
|
||||||
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
|
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const settings: Setting[] = [
|
|||||||
title: "Enabled",
|
title: "Enabled",
|
||||||
category: "Tabs",
|
category: "Tabs",
|
||||||
path: ["tabs", "enabled"],
|
path: ["tabs", "enabled"],
|
||||||
default: true,
|
default: false,
|
||||||
values: [false, true],
|
values: [false, true],
|
||||||
labels: ["off", "on"],
|
labels: ["off", "on"],
|
||||||
},
|
},
|
||||||
@@ -96,16 +96,17 @@ export const settings: Setting[] = [
|
|||||||
title: "Scope",
|
title: "Scope",
|
||||||
category: "Tabs",
|
category: "Tabs",
|
||||||
path: ["tabs", "scope"],
|
path: ["tabs", "scope"],
|
||||||
default: "cwd",
|
default: "global",
|
||||||
values: ["cwd", "global"],
|
values: ["cwd", "global"],
|
||||||
labels: ["current directory", "global"],
|
labels: ["current directory", "global"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Layout",
|
title: "Vertical",
|
||||||
category: "Tabs",
|
category: "Tabs",
|
||||||
path: ["tabs", "layout"],
|
path: ["tabs", "vertical"],
|
||||||
default: "horizontal",
|
default: false,
|
||||||
values: ["horizontal", "vertical"],
|
values: [false, true],
|
||||||
|
labels: ["off", "on"],
|
||||||
keywords: ["sidebar", "orientation", "left"],
|
keywords: ["sidebar", "orientation", "left"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type {
|
|||||||
IntegrationOauthConnectOutput,
|
IntegrationOauthConnectOutput,
|
||||||
IntegrationOAuthMethod,
|
IntegrationOAuthMethod,
|
||||||
} from "@opencode-ai/client"
|
} from "@opencode-ai/client"
|
||||||
import open from "open"
|
|
||||||
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||||
import { useClipboard } from "../context/clipboard"
|
import { useClipboard } from "../context/clipboard"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
@@ -446,19 +445,6 @@ function OAuthAuto(props: {
|
|||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: "modal",
|
mode: "modal",
|
||||||
commands: [
|
commands: [
|
||||||
{
|
|
||||||
bind: "o",
|
|
||||||
title: "Open authorization URL",
|
|
||||||
group: "Dialog",
|
|
||||||
run: () => {
|
|
||||||
open(props.attempt.url).catch(() =>
|
|
||||||
toast.show({
|
|
||||||
message: "Could not open the browser. Copy the URL and continue manually.",
|
|
||||||
variant: "error",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
bind: "c",
|
bind: "c",
|
||||||
title: "Copy authorization details",
|
title: "Copy authorization details",
|
||||||
@@ -516,7 +502,6 @@ function OAuthAuto(props: {
|
|||||||
instructions={props.attempt.instructions}
|
instructions={props.attempt.instructions}
|
||||||
message="Waiting for authorization..."
|
message="Waiting for authorization..."
|
||||||
copy
|
copy
|
||||||
open
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -574,14 +559,7 @@ function OAuthCode(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function OAuthView(props: {
|
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
|
||||||
title: string
|
|
||||||
url?: string
|
|
||||||
instructions?: string
|
|
||||||
message: string
|
|
||||||
copy?: boolean
|
|
||||||
open?: boolean
|
|
||||||
}) {
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const theme = useTheme("elevated")
|
const theme = useTheme("elevated")
|
||||||
return (
|
return (
|
||||||
@@ -605,18 +583,11 @@ function OAuthView(props: {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={theme.text.subdued}>{props.message}</text>
|
<text fg={theme.text.subdued}>{props.message}</text>
|
||||||
<box flexDirection="row" gap={2}>
|
<Show when={props.copy}>
|
||||||
<Show when={props.open}>
|
<text fg={theme.text.default}>
|
||||||
<text fg={theme.text.default}>
|
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||||
o <span style={{ fg: theme.text.subdued }}>open</span>
|
</text>
|
||||||
</text>
|
</Show>
|
||||||
</Show>
|
|
||||||
<Show when={props.copy}>
|
|
||||||
<text fg={theme.text.default}>
|
|
||||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,24 +15,17 @@ import { useConfig } from "../config"
|
|||||||
import { getScrollAcceleration } from "../util/scroll"
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
|
|
||||||
function statusError(status: McpServer["status"]) {
|
function statusError(status: McpServer["status"]) {
|
||||||
if (status.status === "failed") return status.error
|
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function Status(props: { status: McpServer["status"]; loading: boolean }) {
|
function Status(props: { enabled: boolean; loading: boolean }) {
|
||||||
if (props.loading || props.status.status === "pending") {
|
const theme = useTheme("elevated")
|
||||||
return <>Connecting …</>
|
if (props.loading) return <span style={{ fg: theme.text.subdued }}>⋯ Loading</span>
|
||||||
|
if (props.enabled) {
|
||||||
|
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||||
}
|
}
|
||||||
if (props.status.status === "connected") {
|
return <span style={{ fg: theme.text.subdued }}>○ Disabled</span>
|
||||||
return <span style={{ attributes: TextAttributes.BOLD }}>Connected ✓</span>
|
|
||||||
}
|
|
||||||
if (props.status.status === "failed") {
|
|
||||||
return <>Failed !</>
|
|
||||||
}
|
|
||||||
if (props.status.status === "needs_auth") {
|
|
||||||
return <>Sign in required →</>
|
|
||||||
}
|
|
||||||
return <>Disabled ○</>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogMcp() {
|
export function DialogMcp() {
|
||||||
@@ -45,13 +38,6 @@ export function DialogMcp() {
|
|||||||
const [detail, setDetail] = createSignal<McpServer>()
|
const [detail, setDetail] = createSignal<McpServer>()
|
||||||
const [loading, setLoading] = createSignal<string | null>(null)
|
const [loading, setLoading] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const statusColor = (status: McpServer["status"]) => {
|
|
||||||
if (status.status === "connected") return theme.text.feedback.success.default
|
|
||||||
if (status.status === "failed") return theme.text.feedback.error.default
|
|
||||||
if (status.status === "needs_auth") return theme.text.feedback.warning.default
|
|
||||||
return theme.text.subdued
|
|
||||||
}
|
|
||||||
|
|
||||||
const servers = createMemo(() =>
|
const servers = createMemo(() =>
|
||||||
pipe(
|
pipe(
|
||||||
data.location.mcp.server.list() ?? [],
|
data.location.mcp.server.list() ?? [],
|
||||||
@@ -67,29 +53,17 @@ export function DialogMcp() {
|
|||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
const loadingMcp = loading()
|
const loadingMcp = loading()
|
||||||
return servers().map((server) => {
|
return servers().map((server) => ({
|
||||||
const pending = loadingMcp === server.name || server.status.status === "pending"
|
value: server.name,
|
||||||
return {
|
title: server.name,
|
||||||
value: server.name,
|
description: server.status.status,
|
||||||
title: server.name,
|
footer: <Status enabled={server.status.status === "connected"} loading={loadingMcp === server.name} />,
|
||||||
footer: <Status status={server.status} loading={pending} />,
|
}))
|
||||||
footerColor: pending ? theme.text.subdued : statusColor(server.status),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const focusedServer = createMemo(() => servers().find((server) => server.name === focused()))
|
|
||||||
|
|
||||||
const toggleTitle = createMemo(() => {
|
|
||||||
const status = focusedServer()?.status.status
|
|
||||||
if (status === "connected") return "disconnect"
|
|
||||||
if (status === "failed") return "retry"
|
|
||||||
if (status === "needs_auth") return "sign in"
|
|
||||||
return "connect"
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const focusedError = createMemo(() => {
|
const focusedError = createMemo(() => {
|
||||||
const server = focusedServer()
|
const name = focused()
|
||||||
|
const server = servers().find((entry) => entry.name === name)
|
||||||
return server ? statusError(server.status) : undefined
|
return server ? statusError(server.status) : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -126,7 +100,7 @@ export function DialogMcp() {
|
|||||||
onSelect={(option) => open(option.value as string)}
|
onSelect={(option) => open(option.value as string)}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
title: toggleTitle(),
|
title: "toggle",
|
||||||
command: "dialog.mcp.toggle",
|
command: "dialog.mcp.toggle",
|
||||||
onTrigger: (option) => {
|
onTrigger: (option) => {
|
||||||
setFocused(option.value as string)
|
setFocused(option.value as string)
|
||||||
|
|||||||
@@ -8,21 +8,17 @@ import * as fuzzysort from "fuzzysort"
|
|||||||
import { useConnected } from "./use-connected"
|
import { useConnected } from "./use-connected"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
import { modelPreferenceKey } from "../model-preference"
|
import { modelPreferenceKey } from "../model-preference"
|
||||||
import { useLocation } from "../context/location"
|
|
||||||
|
|
||||||
export function DialogModel(props: { providerID?: string }) {
|
export function DialogModel(props: { providerID?: string }) {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const location = useLocation()
|
|
||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal("")
|
||||||
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
|
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
|
||||||
|
|
||||||
const connected = useConnected()
|
const connected = useConnected()
|
||||||
const providers = createMemo(
|
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
|
||||||
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
|
const models = createMemo(() => data.location.model.list() ?? [])
|
||||||
)
|
|
||||||
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
|
|
||||||
|
|
||||||
const showExtra = createMemo(() => connected() && !props.providerID)
|
const showExtra = createMemo(() => connected() && !props.providerID)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function DialogStatus() {
|
|||||||
if (status === "connected") return theme.text.feedback.success.default
|
if (status === "connected") return theme.text.feedback.success.default
|
||||||
if (status === "failed") return theme.text.feedback.error.default
|
if (status === "failed") return theme.text.feedback.error.default
|
||||||
if (status === "needs_auth") return theme.text.feedback.warning.default
|
if (status === "needs_auth") return theme.text.feedback.warning.default
|
||||||
|
if (status === "needs_client_registration") return theme.text.feedback.error.default
|
||||||
return theme.text.subdued
|
return theme.text.subdued
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -45,6 +46,9 @@ export function DialogStatus() {
|
|||||||
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
|
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
|
||||||
<Match when={item.status.status === "disabled"}>Disabled in configuration</Match>
|
<Match when={item.status.status === "disabled"}>Disabled in configuration</Match>
|
||||||
<Match when={item.status.status === "needs_auth"}>Needs authentication</Match>
|
<Match when={item.status.status === "needs_auth"}>Needs authentication</Match>
|
||||||
|
<Match when={item.status.status === "needs_client_registration" && item.status}>
|
||||||
|
{(val) => (val() as { error: string }).error}
|
||||||
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</span>
|
</span>
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -327,6 +327,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
|
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
|
||||||
if (agent && !args.agent) local.agent.set(agent.id)
|
if (agent && !args.agent) local.agent.set(agent.id)
|
||||||
|
if (session.model) {
|
||||||
|
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
|
||||||
|
local.model.variant.set(session.model.variant)
|
||||||
|
}
|
||||||
syncedSessionID = sessionID
|
syncedSessionID = sessionID
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -939,43 +943,15 @@ export function Prompt(props: PromptProps) {
|
|||||||
await slash.command.run(slash.input)
|
await slash.command.run(slash.input)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const inputText = expandTrackedPastedText(
|
|
||||||
store.prompt.text,
|
|
||||||
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
|
||||||
const ref = store.extmarkToPart.get(extmark.id)
|
|
||||||
if (ref?.type !== "pasted") return []
|
|
||||||
const part = store.prompt.pasted[ref.index]
|
|
||||||
if (!part) return []
|
|
||||||
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const slashHead = parseSlashHead(inputText, /\s/)
|
|
||||||
const isSkill =
|
|
||||||
slashHead !== undefined &&
|
|
||||||
(data.location.skill.list(currentLocation.ref) ?? []).some(
|
|
||||||
(skill) => skill.slash === true && skill.id === slashHead.name,
|
|
||||||
)
|
|
||||||
const isCommand =
|
|
||||||
slashHead !== undefined &&
|
|
||||||
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
|
|
||||||
const agent = local.agent.current()
|
const agent = local.agent.current()
|
||||||
if (!agent) return false
|
if (!agent) return false
|
||||||
const selection = local.model.selection()
|
const selectedModel = local.model.current()
|
||||||
if (!selection) {
|
if (!selectedModel) {
|
||||||
void promptModelWarning()
|
void promptModelWarning()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
|
|
||||||
if (usesModel && !local.model.available(selection)) {
|
|
||||||
toast.show({
|
|
||||||
title: "Model unavailable",
|
|
||||||
message: `${selection.providerID}/${selection.modelID} is not available in this session's location`,
|
|
||||||
variant: "warning",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const variant = selection.variant
|
const variant = local.model.variant.current()
|
||||||
let sessionID = props.sessionID
|
let sessionID = props.sessionID
|
||||||
let session = sessionID ? data.session.get(sessionID) : undefined
|
let session = sessionID ? data.session.get(sessionID) : undefined
|
||||||
let finishMoveProgress = false
|
let finishMoveProgress = false
|
||||||
@@ -993,8 +969,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
location: directory ? { directory } : location,
|
location: directory ? { directory } : location,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: {
|
model: {
|
||||||
providerID: selection.providerID,
|
providerID: selectedModel.providerID,
|
||||||
id: selection.modelID,
|
id: selectedModel.modelID,
|
||||||
variant,
|
variant,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1014,6 +990,17 @@ export function Prompt(props: PromptProps) {
|
|||||||
session = created
|
session = created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputText = expandTrackedPastedText(
|
||||||
|
store.prompt.text,
|
||||||
|
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
||||||
|
const ref = store.extmarkToPart.get(extmark.id)
|
||||||
|
if (ref?.type !== "pasted") return []
|
||||||
|
const part = store.prompt.pasted[ref.index]
|
||||||
|
if (!part) return []
|
||||||
|
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
// Capture mode before it gets reset
|
// Capture mode before it gets reset
|
||||||
const currentMode = store.mode
|
const currentMode = store.mode
|
||||||
const editorSelection = editorContext()
|
const editorSelection = editorContext()
|
||||||
@@ -1026,30 +1013,43 @@ export function Prompt(props: PromptProps) {
|
|||||||
command: inputText,
|
command: inputText,
|
||||||
})
|
})
|
||||||
setStore("mode", "normal")
|
setStore("mode", "normal")
|
||||||
} else if (slashHead && isCommand) {
|
} else if (
|
||||||
|
inputText.startsWith("/") &&
|
||||||
|
(data.location.command.list(currentLocation.current) ?? []).some(
|
||||||
|
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||||
|
)
|
||||||
|
) {
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
// Parse command from first line, preserve multi-line content in arguments
|
||||||
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
const firstLineEnd = inputText.indexOf("\n")
|
||||||
|
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
|
||||||
|
const [command, ...firstLineArgs] = firstLine.split(" ")
|
||||||
|
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
||||||
|
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
||||||
|
|
||||||
void client.api.session
|
void client.api.session
|
||||||
.command({
|
.command({
|
||||||
sessionID,
|
sessionID,
|
||||||
command: slashHead.name,
|
command: command.slice(1),
|
||||||
arguments: slashHead.arguments,
|
arguments: args,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model,
|
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||||
files: store.prompt.files,
|
files: store.prompt.files,
|
||||||
agents: store.prompt.agents,
|
agents: store.prompt.agents,
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
cancelCommit()
|
|
||||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||||
})
|
})
|
||||||
} else if (isSkill) {
|
} else if (
|
||||||
|
inputText.startsWith("/") &&
|
||||||
|
(data.location.skill.list(currentLocation.current) ?? []).some(
|
||||||
|
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||||
|
)
|
||||||
|
) {
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
void client.api.session.skill({
|
void client.api.session.skill({
|
||||||
sessionID,
|
sessionID,
|
||||||
skill: slashHead!.name,
|
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
@@ -1061,15 +1061,13 @@ export function Prompt(props: PromptProps) {
|
|||||||
await client.api.session.switchAgent({ sessionID, agent: agent.id })
|
await client.api.session.switchAgent({ sessionID, agent: agent.id })
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
session?.model?.providerID !== selection.providerID ||
|
session?.model?.providerID !== selectedModel.providerID ||
|
||||||
session.model.id !== selection.modelID ||
|
session.model.id !== selectedModel.modelID ||
|
||||||
(session.model.variant ?? "default") !== (variant ?? "default")
|
(session.model.variant ?? "default") !== (variant ?? "default")
|
||||||
) {
|
) {
|
||||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
await client.api.session.switchModel({
|
||||||
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
sessionID,
|
||||||
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
|
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||||
cancelCommit()
|
|
||||||
throw error
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (session?.revert) {
|
if (session?.revert) {
|
||||||
@@ -1322,7 +1320,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
return `Ask anything... "${list()[store.placeholder % list().length]}"`
|
return `Ask anything... "${list()[store.placeholder % list().length]}"`
|
||||||
})()
|
})()
|
||||||
if (!value) return undefined
|
if (!value) return undefined
|
||||||
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
|
const width =
|
||||||
|
dimensions().width < 44
|
||||||
|
? dimensions().width - 5
|
||||||
|
: Math.min(75, dimensions().width - 4) - 5
|
||||||
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
|
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
|
||||||
})
|
})
|
||||||
const locationLabel = createMemo(() => {
|
const locationLabel = createMemo(() => {
|
||||||
|
|||||||
@@ -132,8 +132,8 @@ export const Info = Schema.Struct({
|
|||||||
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
|
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
|
||||||
description: "Share tabs globally or keep a separate set for each working directory",
|
description: "Share tabs globally or keep a separate set for each working directory",
|
||||||
}),
|
}),
|
||||||
layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({
|
vertical: Schema.optional(Schema.Boolean).annotate({
|
||||||
description: "Show tabs in a horizontal strip or vertical sidebar",
|
description: "Show tabs in a left sidebar instead of a horizontal strip",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
).annotate({ description: "Tab strip settings" }),
|
).annotate({ description: "Tab strip settings" }),
|
||||||
@@ -179,7 +179,7 @@ export const Info = Schema.Struct({
|
|||||||
})
|
})
|
||||||
export type Info = Schema.Schema.Type<typeof Info>
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
|
|
||||||
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse" | "tabs"> & {
|
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"> & {
|
||||||
attention: {
|
attention: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
notifications: boolean
|
notifications: boolean
|
||||||
@@ -191,11 +191,6 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"
|
|||||||
keybinds: TuiKeybind.BindingLookupView
|
keybinds: TuiKeybind.BindingLookupView
|
||||||
leader: { timeout: number }
|
leader: { timeout: number }
|
||||||
mouse: boolean
|
mouse: boolean
|
||||||
tabs: {
|
|
||||||
enabled: boolean
|
|
||||||
scope: "global" | "cwd"
|
|
||||||
layout: "horizontal" | "vertical"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
|
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
|
||||||
@@ -226,12 +221,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
|||||||
}),
|
}),
|
||||||
leader: { timeout: input.leader?.timeout ?? 2000 },
|
leader: { timeout: input.leader?.timeout ?? 2000 },
|
||||||
mouse: input.mouse ?? true,
|
mouse: input.mouse ?? true,
|
||||||
tabs: {
|
|
||||||
...input.tabs,
|
|
||||||
enabled: input.tabs?.enabled ?? true,
|
|
||||||
scope: input.tabs?.scope ?? "cwd",
|
|
||||||
layout: input.tabs?.layout ?? "horizontal",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { dedupeWith } from "effect/Array"
|
import { dedupeWith } from "effect/Array"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { batch, createMemo, onCleanup } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { useEvent } from "./event"
|
import { useEvent } from "./event"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { useTuiPaths } from "./runtime"
|
import { useTuiPaths } from "./runtime"
|
||||||
@@ -22,7 +22,6 @@ import { useToast } from "../ui/toast"
|
|||||||
import { useRoute } from "./route"
|
import { useRoute } from "./route"
|
||||||
import { useData } from "./data"
|
import { useData } from "./data"
|
||||||
import { usePermission } from "./permission"
|
import { usePermission } from "./permission"
|
||||||
import { useLocation } from "./location"
|
|
||||||
|
|
||||||
export function parseModel(model: string) {
|
export function parseModel(model: string) {
|
||||||
const [providerID, ...rest] = model.split("/")
|
const [providerID, ...rest] = model.split("/")
|
||||||
@@ -58,29 +57,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
const event = useEvent()
|
const event = useEvent()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
const models = () => data.location.model.list(location.ref)
|
|
||||||
const providers = () => data.location.provider.list(location.ref)
|
|
||||||
|
|
||||||
function isModelValid(model: ModelPreferenceModel) {
|
function isModelValid(model: ModelPreferenceModel) {
|
||||||
return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
|
return !!data.location.model
|
||||||
|
.list()
|
||||||
|
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
|
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
|
||||||
for (const modelFn of modelFns) {
|
for (const modelFn of modelFns) {
|
||||||
const model = modelFn()
|
const model = modelFn()
|
||||||
if (model && isModelValid(model)) return model
|
if (!model) continue
|
||||||
|
if (isModelValid(model)) return model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAgent() {
|
function createAgent() {
|
||||||
const agents = createMemo(() =>
|
const agents = createMemo(() =>
|
||||||
(data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
|
(data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
|
||||||
)
|
|
||||||
const visibleAgents = createMemo(() =>
|
|
||||||
(data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden),
|
|
||||||
)
|
)
|
||||||
|
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
|
||||||
const [agentStore, setAgentStore] = createStore({
|
const [agentStore, setAgentStore] = createStore({
|
||||||
current: undefined as string | undefined,
|
current: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
@@ -132,40 +128,35 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const agent = createAgent()
|
const agent = createAgent()
|
||||||
|
|
||||||
function createModel() {
|
function createModel() {
|
||||||
type ModelSelection = ModelPreferenceModel & { variant?: string }
|
const [modelStore, setModelStore] = createStore<
|
||||||
const [preferences, setPreferences] = createStore<ModelPreference & { ready: boolean }>({
|
ModelPreference & {
|
||||||
|
ready: boolean
|
||||||
|
model: Record<string, ModelPreferenceModel>
|
||||||
|
}
|
||||||
|
>({
|
||||||
ready: false,
|
ready: false,
|
||||||
|
model: {},
|
||||||
recent: [],
|
recent: [],
|
||||||
favorite: [],
|
favorite: [],
|
||||||
variant: {},
|
variant: {},
|
||||||
})
|
})
|
||||||
const [selectionState, setSelectionState] = createStore<{
|
|
||||||
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
|
|
||||||
draftBySession: Record<string, ModelSelection | undefined>
|
|
||||||
}>({
|
|
||||||
newSessionModelByLocationAgent: {},
|
|
||||||
draftBySession: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
|
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
|
||||||
const pendingSelectionCommits = new Map<string, string>()
|
const state = {
|
||||||
const selectionKey = (value: ModelSelection) =>
|
|
||||||
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
|
|
||||||
const saveState = {
|
|
||||||
pending: false,
|
pending: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
function savePreferences() {
|
function save() {
|
||||||
if (!preferences.ready) {
|
if (!modelStore.ready) {
|
||||||
saveState.pending = true
|
state.pending = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
saveState.pending = false
|
state.pending = false
|
||||||
void repository
|
void repository
|
||||||
.patch({
|
.patch({
|
||||||
recent: preferences.recent,
|
recent: modelStore.recent,
|
||||||
favorite: preferences.favorite,
|
favorite: modelStore.favorite,
|
||||||
variant: preferences.variant,
|
variant: modelStore.variant,
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
@@ -173,14 +164,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
repository
|
repository
|
||||||
.load()
|
.load()
|
||||||
.then((value) => {
|
.then((value) => {
|
||||||
setPreferences("recent", value.recent)
|
setModelStore("recent", value.recent)
|
||||||
setPreferences("favorite", value.favorite)
|
setModelStore("favorite", value.favorite)
|
||||||
setPreferences("variant", value.variant)
|
setModelStore("variant", value.variant)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setPreferences("ready", true)
|
setModelStore("ready", true)
|
||||||
if (saveState.pending) savePreferences()
|
if (state.pending) save()
|
||||||
})
|
})
|
||||||
|
|
||||||
const fallbackModel = createMemo(() => {
|
const fallbackModel = createMemo(() => {
|
||||||
@@ -194,13 +185,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of preferences.recent) {
|
for (const item of modelStore.recent) {
|
||||||
if (isModelValid(item)) {
|
if (isModelValid(item)) {
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = models()?.[0]
|
const model = data.location.model.list()?.[0]
|
||||||
if (!model) return undefined
|
if (!model) return undefined
|
||||||
return {
|
return {
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
@@ -208,134 +199,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const newSessionModel = createMemo(() => {
|
|
||||||
const a = agent.current()
|
|
||||||
return getFirstValidModel(
|
|
||||||
() => a && selectionState.newSessionModelByLocationAgent[locationAgentKey(a.id)],
|
|
||||||
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
|
|
||||||
fallbackModel,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentSelection = createMemo<ModelSelection | undefined>(() => {
|
|
||||||
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
|
|
||||||
const model = newSessionModel()
|
|
||||||
if (!model) return
|
|
||||||
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentModel = createMemo(() => {
|
const currentModel = createMemo(() => {
|
||||||
const selection = currentSelection()
|
const a = agent.current()
|
||||||
if (!selection) return
|
return (
|
||||||
return { providerID: selection.providerID, modelID: selection.modelID }
|
getFirstValidModel(
|
||||||
})
|
() => a && modelStore.model[a.id],
|
||||||
|
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
|
||||||
function locationAgentKey(agentID: string) {
|
fallbackModel,
|
||||||
const ref = location.ref ?? data.location.default()
|
) ?? undefined
|
||||||
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function durableSelection(sessionID: string): ModelSelection | undefined {
|
|
||||||
const model = data.session.get(sessionID)?.model
|
|
||||||
if (!model) return
|
|
||||||
return {
|
|
||||||
providerID: model.providerID,
|
|
||||||
modelID: model.id,
|
|
||||||
variant: normalizeModelVariant(model.variant),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionSelection(sessionID: string) {
|
|
||||||
return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSessionDraft(sessionID: string, selection: ModelSelection) {
|
|
||||||
const durable = durableSelection(sessionID)
|
|
||||||
setSelectionState(
|
|
||||||
"draftBySession",
|
|
||||||
sessionID,
|
|
||||||
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
|
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
function selectModel(model: ModelPreferenceModel) {
|
|
||||||
if (route.data.type === "session") {
|
|
||||||
const sessionID = route.data.sessionID
|
|
||||||
const current = sessionSelection(sessionID)
|
|
||||||
const preferred = normalizeModelVariant(
|
|
||||||
current?.providerID === model.providerID && current.modelID === model.modelID
|
|
||||||
? current.variant
|
|
||||||
: preferences.variant[modelPreferenceKey(model)],
|
|
||||||
)
|
|
||||||
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
|
|
||||||
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
|
|
||||||
setSessionDraft(sessionID, { ...model, variant })
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const current = agent.current()
|
|
||||||
if (!current) return false
|
|
||||||
setSelectionState("newSessionModelByLocationAgent", locationAgentKey(current.id), model)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(
|
|
||||||
event.on("session.model.selected", (evt) => {
|
|
||||||
const expected = pendingSelectionCommits.get(evt.data.sessionID)
|
|
||||||
if (!expected) return
|
|
||||||
const committed = selectionKey({
|
|
||||||
providerID: evt.data.model.providerID,
|
|
||||||
modelID: evt.data.model.id,
|
|
||||||
variant: evt.data.model.variant,
|
|
||||||
})
|
|
||||||
if (committed !== expected) return
|
|
||||||
pendingSelectionCommits.delete(evt.data.sessionID)
|
|
||||||
const draft = selectionState.draftBySession[evt.data.sessionID]
|
|
||||||
if (draft && selectionKey(draft) === committed)
|
|
||||||
setSelectionState("draftBySession", evt.data.sessionID, undefined)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
onCleanup(
|
|
||||||
event.on("session.deleted", (evt) => {
|
|
||||||
pendingSelectionCommits.delete(evt.data.sessionID)
|
|
||||||
setSelectionState("draftBySession", evt.data.sessionID, undefined)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
current: currentModel,
|
current: currentModel,
|
||||||
selection: currentSelection,
|
|
||||||
available(model = currentModel()) {
|
|
||||||
return model ? isModelValid(model) : false
|
|
||||||
},
|
|
||||||
trackSessionCommit(
|
|
||||||
sessionID: string,
|
|
||||||
value: {
|
|
||||||
providerID: string
|
|
||||||
id: string
|
|
||||||
variant?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
|
|
||||||
pendingSelectionCommits.set(sessionID, committed)
|
|
||||||
return () => {
|
|
||||||
if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
get ready() {
|
get ready() {
|
||||||
return preferences.ready
|
return modelStore.ready
|
||||||
},
|
|
||||||
get catalogReady() {
|
|
||||||
return models() !== undefined
|
|
||||||
},
|
},
|
||||||
recent() {
|
recent() {
|
||||||
return preferences.recent
|
return modelStore.recent
|
||||||
},
|
},
|
||||||
favorite() {
|
favorite() {
|
||||||
return preferences.favorite
|
return modelStore.favorite
|
||||||
},
|
},
|
||||||
parsed: createMemo(() => {
|
parsed: createMemo(() => {
|
||||||
const value = currentSelection()
|
const value = currentModel()
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return {
|
return {
|
||||||
provider: "Connect a provider",
|
provider: "Connect a provider",
|
||||||
@@ -343,28 +230,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
reasoning: false,
|
reasoning: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const provider = providers()?.find((item) => item.id === value.providerID)
|
const provider = data.location.provider.list()?.find((item) => item.id === value.providerID)
|
||||||
const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
|
const info = data.location.model
|
||||||
|
.list()
|
||||||
|
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
|
||||||
return {
|
return {
|
||||||
provider: provider?.name ?? value.providerID,
|
provider: provider?.name ?? value.providerID,
|
||||||
model: info?.name ?? `${value.modelID} (unavailable)`,
|
model: info?.name ?? value.modelID,
|
||||||
reasoning: (info?.variants?.length ?? 0) !== 0,
|
reasoning: (info?.variants?.length ?? 0) !== 0,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
cycle(direction: 1 | -1) {
|
cycle(direction: 1 | -1) {
|
||||||
const current = currentSelection()
|
const current = currentModel()
|
||||||
if (!current) return
|
if (!current) return
|
||||||
const recent = recentModels(current, preferences.recent).filter(isModelValid)
|
const recent = modelStore.recent
|
||||||
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
||||||
let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction
|
if (index === -1) return
|
||||||
|
let next = index + direction
|
||||||
if (next < 0) next = recent.length - 1
|
if (next < 0) next = recent.length - 1
|
||||||
if (next >= recent.length) next = 0
|
if (next >= recent.length) next = 0
|
||||||
const val = recent[next]
|
const val = recent[next]
|
||||||
if (!val) return
|
if (!val) return
|
||||||
selectModel({ ...val })
|
const a = agent.current()
|
||||||
|
if (!a) return
|
||||||
|
setModelStore("model", a.id, { ...val })
|
||||||
},
|
},
|
||||||
cycleFavorite(direction: 1 | -1) {
|
cycleFavorite(direction: 1 | -1) {
|
||||||
const favorites = preferences.favorite.filter((item) => isModelValid(item))
|
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
|
||||||
if (!favorites.length) {
|
if (!favorites.length) {
|
||||||
toast.show({
|
toast.show({
|
||||||
variant: "info",
|
variant: "info",
|
||||||
@@ -373,7 +265,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const current = currentSelection()
|
const current = currentModel()
|
||||||
let index = -1
|
let index = -1
|
||||||
if (current) {
|
if (current) {
|
||||||
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
||||||
@@ -387,39 +279,45 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
const next = favorites[index]
|
const next = favorites[index]
|
||||||
if (!next) return
|
if (!next) return
|
||||||
if (!selectModel({ ...next })) return
|
const a = agent.current()
|
||||||
setPreferences("recent", recentModels(next, preferences.recent))
|
if (!a) return
|
||||||
savePreferences()
|
setModelStore("model", a.id, { ...next })
|
||||||
|
setModelStore("recent", recentModels(next, modelStore.recent))
|
||||||
|
save()
|
||||||
},
|
},
|
||||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
if (!isModelValid(model)) return
|
if (!isModelValid(model)) return
|
||||||
if (!selectModel(model)) return
|
const a = agent.current()
|
||||||
|
if (!a) return
|
||||||
|
setModelStore("model", a.id, model)
|
||||||
if (options?.recent) {
|
if (options?.recent) {
|
||||||
setPreferences("recent", recentModels(model, preferences.recent))
|
setModelStore("recent", recentModels(model, modelStore.recent))
|
||||||
savePreferences()
|
save()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
toggleFavorite(model: { providerID: string; modelID: string }) {
|
toggleFavorite(model: { providerID: string; modelID: string }) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
if (!isModelValid(model)) return
|
if (!isModelValid(model)) return
|
||||||
const exists = preferences.favorite.some(
|
const exists = modelStore.favorite.some(
|
||||||
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
|
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
|
||||||
)
|
)
|
||||||
const next = exists
|
const next = exists
|
||||||
? preferences.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
|
? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
|
||||||
: [model, ...preferences.favorite]
|
: [model, ...modelStore.favorite]
|
||||||
setPreferences(
|
setModelStore(
|
||||||
"favorite",
|
"favorite",
|
||||||
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
|
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
|
||||||
)
|
)
|
||||||
savePreferences()
|
save()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
variant: {
|
variant: {
|
||||||
selected() {
|
selected() {
|
||||||
return currentSelection()?.variant
|
const m = currentModel()
|
||||||
|
if (!m) return undefined
|
||||||
|
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
|
||||||
},
|
},
|
||||||
current() {
|
current() {
|
||||||
const v = this.selected()
|
const v = this.selected()
|
||||||
@@ -427,20 +325,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
list() {
|
list() {
|
||||||
const m = currentSelection()
|
const m = currentModel()
|
||||||
if (!m) return []
|
if (!m) return []
|
||||||
const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
|
const info = data.location.model
|
||||||
|
.list()
|
||||||
|
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
|
||||||
return info?.variants?.map((variant) => variant.id) ?? []
|
return info?.variants?.map((variant) => variant.id) ?? []
|
||||||
},
|
},
|
||||||
set(value: string | undefined) {
|
set(value: string | undefined) {
|
||||||
const m = currentSelection()
|
const m = currentModel()
|
||||||
if (!m) return
|
if (!m) return
|
||||||
if (route.data.type === "session") {
|
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
||||||
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
|
save()
|
||||||
return
|
|
||||||
}
|
|
||||||
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
|
||||||
savePreferences()
|
|
||||||
},
|
},
|
||||||
cycle() {
|
cycle() {
|
||||||
const variants = this.list()
|
const variants = this.list()
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||||||
const event = useEvent()
|
const event = useEvent()
|
||||||
const config = useConfig().data
|
const config = useConfig().data
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
const enabled = () => config.tabs.enabled
|
const enabled = () => config.tabs?.enabled ?? false
|
||||||
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
|
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
|
||||||
// mutating in place, which per-row animations and drag state depend on.
|
// mutating in place, which per-row animations and drag state depend on.
|
||||||
const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
|
const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
|
||||||
@@ -66,12 +66,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||||||
let closedTabs: ClosedSessionTab[] = []
|
let closedTabs: ClosedSessionTab[] = []
|
||||||
|
|
||||||
function state() {
|
function state() {
|
||||||
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
|
if (config.tabs?.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
|
||||||
return store.global
|
return store.global
|
||||||
}
|
}
|
||||||
|
|
||||||
function update(mutation: (draft: TabsState) => void) {
|
function update(mutation: (draft: TabsState) => void) {
|
||||||
const scope = config.tabs.scope
|
const scope = config.tabs?.scope ?? "global"
|
||||||
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
|
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
|
||||||
// Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
|
// Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
|
||||||
(error) => console.error("Failed to persist session tabs", error),
|
(error) => console.error("Failed to persist session tabs", error),
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
|||||||
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
|
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
|
||||||
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
|
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
|
||||||
const bad = createMemo(
|
const bad = createMemo(
|
||||||
() => list().filter((item) => item.status.status === "failed" || item.status.status === "needs_auth").length,
|
() =>
|
||||||
|
list().filter(
|
||||||
|
(item) =>
|
||||||
|
item.status.status === "failed" ||
|
||||||
|
item.status.status === "needs_auth" ||
|
||||||
|
item.status.status === "needs_client_registration",
|
||||||
|
).length,
|
||||||
)
|
)
|
||||||
|
|
||||||
const dot = (status: string) => {
|
const dot = (status: string) => {
|
||||||
@@ -16,6 +22,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
|||||||
if (status === "failed") return theme.text.feedback.error.default
|
if (status === "failed") return theme.text.feedback.error.default
|
||||||
if (status === "disabled") return theme.text.subdued
|
if (status === "disabled") return theme.text.subdued
|
||||||
if (status === "needs_auth") return theme.text.feedback.warning.default
|
if (status === "needs_auth") return theme.text.feedback.warning.default
|
||||||
|
if (status === "needs_client_registration") return theme.text.feedback.error.default
|
||||||
return theme.text.subdued
|
return theme.text.subdued
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +65,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={item.status.status === "disabled"}>Disabled</Match>
|
<Match when={item.status.status === "disabled"}>Disabled</Match>
|
||||||
<Match when={item.status.status === "needs_auth"}>Needs auth</Match>
|
<Match when={item.status.status === "needs_auth"}>Needs auth</Match>
|
||||||
|
<Match when={item.status.status === "needs_client_registration"}>Needs client ID</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</span>
|
</span>
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -93,13 +93,13 @@ export function Home() {
|
|||||||
<box width="100%" flexShrink={0}>
|
<box width="100%" flexShrink={0}>
|
||||||
<PluginSlot name="home.footer" input={{}} mode="replace" />
|
<PluginSlot name="home.footer" input={{}} mode="replace" />
|
||||||
</box>
|
</box>
|
||||||
<Show when={forms()[0]?.id} keyed>
|
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
const form = forms()[0]
|
const form = forms()[0]
|
||||||
return form ? (
|
return form ? (
|
||||||
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
|
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
|
||||||
<box width="100%">
|
<box width="100%">
|
||||||
<FormPrompt form={form} />
|
<FormPrompt form={form} forms={forms()} />
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
) : null
|
) : null
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function requestOptions(form: FormWithLocation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
export function FormPrompt(props: { form: FormWithLocation; forms?: readonly FormWithLocation[] }) {
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const themes = useThemes()
|
const themes = useThemes()
|
||||||
const theme = useTheme("elevated")
|
const theme = useTheme("elevated")
|
||||||
@@ -69,6 +69,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
let textarea: TextareaRenderable | undefined
|
let textarea: TextareaRenderable | undefined
|
||||||
let review: ScrollBoxRenderable | undefined
|
let review: ScrollBoxRenderable | undefined
|
||||||
|
|
||||||
|
const forms = createMemo(() => {
|
||||||
|
if (!props.form.coalesce) return [props.form]
|
||||||
|
return (props.forms ?? [props.form]).filter((form) => form.coalesce === props.form.coalesce)
|
||||||
|
})
|
||||||
|
|
||||||
const message = createMemo(() => {
|
const message = createMemo(() => {
|
||||||
const value = props.form.metadata?.["message"]
|
const value = props.form.metadata?.["message"]
|
||||||
return typeof value === "string" ? value : undefined
|
return typeof value === "string" ? value : undefined
|
||||||
@@ -180,24 +185,30 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
setStore("error", "")
|
setStore("error", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
function reply(answer: Record<string, FormValue>) {
|
||||||
client.api.form
|
Promise.all(
|
||||||
.reply(
|
forms().map((form) =>
|
||||||
{
|
client.api.form.reply(
|
||||||
sessionID: props.form.sessionID,
|
{
|
||||||
formID: props.form.id,
|
sessionID: form.sessionID,
|
||||||
answer: { [field.key]: value },
|
formID: form.id,
|
||||||
},
|
answer,
|
||||||
requestOptions(props.form),
|
},
|
||||||
|
requestOptions(form),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).catch((error: unknown) => {
|
||||||
|
setStore(
|
||||||
|
"error",
|
||||||
|
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||||
|
? error.message
|
||||||
|
: "Invalid answer",
|
||||||
)
|
)
|
||||||
.catch((error: unknown) => {
|
})
|
||||||
setStore(
|
}
|
||||||
"error",
|
|
||||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||||
? error.message
|
reply({ [field.key]: value })
|
||||||
: "Invalid answer",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pick(value: FormValue, customValue?: string) {
|
function pick(value: FormValue, customValue?: string) {
|
||||||
@@ -350,7 +361,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
for (const form of forms())
|
||||||
|
void client.api.form.cancel({ sessionID: form.sessionID, formID: form.id }, requestOptions(form))
|
||||||
}
|
}
|
||||||
|
|
||||||
function openExternal() {
|
function openExternal() {
|
||||||
@@ -402,28 +414,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
client.api.form
|
reply(
|
||||||
.reply(
|
Object.fromEntries(
|
||||||
{
|
fields().flatMap((field) => {
|
||||||
sessionID: props.form.sessionID,
|
const value = store.answers[field.key]
|
||||||
formID: props.form.id,
|
return value === undefined ? [] : [[field.key, value] as const]
|
||||||
answer: Object.fromEntries(
|
}),
|
||||||
fields().flatMap((field) => {
|
),
|
||||||
const value = store.answers[field.key]
|
)
|
||||||
return value === undefined ? [] : [[field.key, value] as const]
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
requestOptions(props.form),
|
|
||||||
)
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
setStore(
|
|
||||||
"error",
|
|
||||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
|
||||||
? error.message
|
|
||||||
: "Invalid answer",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
|
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export function Session() {
|
|||||||
const availableWidth = createMemo(
|
const availableWidth = createMemo(
|
||||||
() =>
|
() =>
|
||||||
dimensions().width -
|
dimensions().width -
|
||||||
(config.tabs?.enabled && config.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
|
(config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width)
|
||||||
? SESSION_SIDEBAR_WIDTH
|
? SESSION_SIDEBAR_WIDTH
|
||||||
: 0),
|
: 0),
|
||||||
)
|
)
|
||||||
@@ -361,7 +361,7 @@ export function Session() {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current = prompt()
|
const current = prompt()
|
||||||
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return
|
if (sent || !current || !synced() || !local.model.ready) return
|
||||||
if (!local.agent.current() || !local.model.current()) return
|
if (!local.agent.current() || !local.model.current()) return
|
||||||
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
|
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
|
||||||
sent = true
|
sent = true
|
||||||
@@ -824,13 +824,22 @@ export function Session() {
|
|||||||
if (options === null) return
|
if (options === null) return
|
||||||
|
|
||||||
const content =
|
const content =
|
||||||
options.format === "markdown"
|
options.format === "markdown"
|
||||||
? formatSessionTranscript(sessionData, messages(), options.thinking)
|
? formatSessionTranscript(sessionData, messages(), options.thinking)
|
||||||
: JSON.stringify(
|
: await (async () => {
|
||||||
await client.api.session.export({ sessionID: sessionData.id, sanitize: options.sanitize }),
|
const messages: unknown[] = []
|
||||||
null,
|
let cursor: string | undefined
|
||||||
2,
|
do {
|
||||||
) + EOL
|
const page = await client.api.message.list(
|
||||||
|
cursor
|
||||||
|
? { sessionID: sessionData.id, limit: 200, cursor }
|
||||||
|
: { sessionID: sessionData.id, limit: 200, order: "asc" },
|
||||||
|
)
|
||||||
|
messages.push(...page.data)
|
||||||
|
cursor = page.data.length ? (page.cursor.next ?? undefined) : undefined
|
||||||
|
} while (cursor)
|
||||||
|
return JSON.stringify({ info: sessionData, messages }, null, 2) + EOL
|
||||||
|
})()
|
||||||
|
|
||||||
if (options.action === "copy") {
|
if (options.action === "copy") {
|
||||||
await clipboard.write?.(content)
|
await clipboard.write?.(content)
|
||||||
@@ -1017,10 +1026,10 @@ export function Session() {
|
|||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={forms().length > 0}>
|
<Match when={forms().length > 0}>
|
||||||
<Show when={forms()[0]?.id} keyed>
|
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
const form = forms()[0]
|
const form = forms()[0]
|
||||||
return form ? <FormPrompt form={form} /> : null
|
return form ? <FormPrompt form={form} forms={forms()} /> : null
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ export type ExportFormat = "markdown" | "json"
|
|||||||
|
|
||||||
export type DialogExportOptionsProps = {
|
export type DialogExportOptionsProps = {
|
||||||
defaultThinking: boolean
|
defaultThinking: boolean
|
||||||
onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; thinking: boolean; sanitize: boolean }) => void
|
onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; thinking: boolean }) => void
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Active = ExportFormat | "thinking" | "sanitize" | "copy" | "export"
|
type Active = ExportFormat | "thinking" | "copy" | "export"
|
||||||
|
|
||||||
export function DialogExportOptions(props: DialogExportOptionsProps) {
|
export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -22,7 +22,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
format: "markdown" as ExportFormat,
|
format: "markdown" as ExportFormat,
|
||||||
thinking: props.defaultThinking,
|
thinking: props.defaultThinking,
|
||||||
sanitize: false,
|
|
||||||
active: "markdown" as Active,
|
active: "markdown" as Active,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -31,7 +30,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
action,
|
action,
|
||||||
format: store.format,
|
format: store.format,
|
||||||
thinking: store.thinking,
|
thinking: store.thinking,
|
||||||
sanitize: store.sanitize,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const activate = () => {
|
const activate = () => {
|
||||||
@@ -40,7 +38,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
||||||
if (store.active === "sanitize") setStore("sanitize", !store.sanitize)
|
|
||||||
if (store.active === "copy" || store.active === "export") confirm(store.active)
|
if (store.active === "copy" || store.active === "export") confirm(store.active)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +52,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
const order: Active[] =
|
const order: Active[] =
|
||||||
store.format === "markdown"
|
store.format === "markdown"
|
||||||
? ["markdown", "json", "thinking", "copy", "export"]
|
? ["markdown", "json", "thinking", "copy", "export"]
|
||||||
: ["markdown", "json", "sanitize", "copy", "export"]
|
: ["markdown", "json", "copy", "export"]
|
||||||
setStore("active", order[(order.indexOf(store.active) + 1) % order.length])
|
setStore("active", order[(order.indexOf(store.active) + 1) % order.length])
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -156,46 +153,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={store.format === "json"}>
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
backgroundColor={
|
|
||||||
store.active === "sanitize"
|
|
||||||
? theme.background.formfield.focused
|
|
||||||
: store.sanitize
|
|
||||||
? theme.background.formfield.selected
|
|
||||||
: theme.background.formfield.default
|
|
||||||
}
|
|
||||||
onMouseUp={() => {
|
|
||||||
setStore("active", "sanitize")
|
|
||||||
setStore("sanitize", !store.sanitize)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
store.active === "sanitize"
|
|
||||||
? theme.text.formfield.focused
|
|
||||||
: store.sanitize
|
|
||||||
? theme.text.formfield.selected
|
|
||||||
: theme.text.formfield.default
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{store.sanitize ? "[x]" : "[ ]"}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
store.active === "sanitize"
|
|
||||||
? theme.text.formfield.focused
|
|
||||||
: store.sanitize
|
|
||||||
? theme.text.formfield.selected
|
|
||||||
: theme.text.formfield.default
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Sanitize sensitive data
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
|
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
|
||||||
<box
|
<box
|
||||||
paddingLeft={4}
|
paddingLeft={4}
|
||||||
@@ -229,7 +186,6 @@ DialogExportOptions.show = (dialog: DialogContext, defaultThinking: boolean) =>
|
|||||||
action: "copy" | "export"
|
action: "copy" | "export"
|
||||||
format: ExportFormat
|
format: ExportFormat
|
||||||
thinking: boolean
|
thinking: boolean
|
||||||
sanitize: boolean
|
|
||||||
} | null>((resolve) => {
|
} | null>((resolve) => {
|
||||||
dialog.replace(
|
dialog.replace(
|
||||||
() => (
|
() => (
|
||||||
|
|||||||
@@ -84,8 +84,7 @@ export function DialogPrompt(props: DialogPromptProps) {
|
|||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
{props.description?.()}
|
{props.description?.()}
|
||||||
<textarea
|
<textarea
|
||||||
height={1}
|
height={3}
|
||||||
wrapMode="none"
|
|
||||||
ref={(val: TextareaRenderable) => {
|
ref={(val: TextareaRenderable) => {
|
||||||
textarea = val
|
textarea = val
|
||||||
setTextareaTarget(val)
|
setTextareaTarget(val)
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ export interface DialogSelectOption<T = any> {
|
|||||||
detailsColor?: RGBA
|
detailsColor?: RGBA
|
||||||
detailsWrap?: boolean
|
detailsWrap?: boolean
|
||||||
footer?: JSX.Element | string
|
footer?: JSX.Element | string
|
||||||
footerColor?: RGBA
|
|
||||||
titleWidth?: number
|
titleWidth?: number
|
||||||
truncateTitle?: boolean | "left"
|
truncateTitle?: boolean | "left"
|
||||||
category?: string
|
category?: string
|
||||||
@@ -728,7 +727,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
footer={
|
footer={
|
||||||
flatten() ? (option.searchFooter ?? option.category ?? option.footer) : option.footer
|
flatten() ? (option.searchFooter ?? option.category ?? option.footer) : option.footer
|
||||||
}
|
}
|
||||||
footerColor={option.footerColor}
|
|
||||||
titleWidth={option.titleWidth}
|
titleWidth={option.titleWidth}
|
||||||
truncateTitle={option.truncateTitle}
|
truncateTitle={option.truncateTitle}
|
||||||
description={option.description !== category ? option.description : undefined}
|
description={option.description !== category ? option.description : undefined}
|
||||||
@@ -786,7 +784,6 @@ function Option(props: {
|
|||||||
current?: boolean
|
current?: boolean
|
||||||
muted?: boolean
|
muted?: boolean
|
||||||
footer?: JSX.Element | string
|
footer?: JSX.Element | string
|
||||||
footerColor?: RGBA
|
|
||||||
titleWidth?: number
|
titleWidth?: number
|
||||||
truncateTitle?: boolean | "left"
|
truncateTitle?: boolean | "left"
|
||||||
gutter?: () => JSX.Element
|
gutter?: () => JSX.Element
|
||||||
@@ -835,17 +832,7 @@ function Option(props: {
|
|||||||
</text>
|
</text>
|
||||||
<Show when={props.footer}>
|
<Show when={props.footer}>
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
<text
|
<text fg={props.active && !props.muted ? text() : theme.text.subdued}>{props.footer}</text>
|
||||||
fg={
|
|
||||||
props.active && !props.muted
|
|
||||||
? text()
|
|
||||||
: props.muted && (props.active || props.current)
|
|
||||||
? theme.text.subdued
|
|
||||||
: (props.footerColor ?? theme.text.subdued)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{props.footer}
|
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function Link(props: LinkProps) {
|
|||||||
open(props.href).catch(() => {})
|
open(props.href).catch(() => {})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<a href={props.href}>{displayText}</a>
|
{displayText}
|
||||||
</text>
|
</text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
|||||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
||||||
|
|
||||||
async function mountForm(root: string, width = 80) {
|
async function mountForm(root: string, width = 80, coalesce = false) {
|
||||||
const state = path.join(root, "state")
|
const state = path.join(root, "state")
|
||||||
await mkdir(state, { recursive: true })
|
await mkdir(state, { recursive: true })
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ async function mountForm(root: string, width = 80) {
|
|||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const transport = createFetch(
|
const transport = createFetch(
|
||||||
(url, request) =>
|
(url, request) =>
|
||||||
url.pathname === "/api/session/ses_test/form/frm_test/reply"
|
/^\/api\/session\/ses_test\/form\/frm_(?:test|other)\/reply$/.test(url.pathname)
|
||||||
? request.json().then((answer) => {
|
? request.json().then((answer) => {
|
||||||
replies.push(answer)
|
replies.push(answer)
|
||||||
return new Response(null, { status: 204 })
|
return new Response(null, { status: 204 })
|
||||||
@@ -37,6 +37,7 @@ async function mountForm(root: string, width = 80) {
|
|||||||
id: "frm_test",
|
id: "frm_test",
|
||||||
sessionID: "ses_test",
|
sessionID: "ses_test",
|
||||||
title: "Authorization required",
|
title: "Authorization required",
|
||||||
|
...(coalesce ? { coalesce: "authorization" } : {}),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
key: "authorization",
|
key: "authorization",
|
||||||
@@ -71,7 +72,7 @@ async function mountForm(root: string, width = 80) {
|
|||||||
<ClientProvider api={createApi(transport.fetch)}>
|
<ClientProvider api={createApi(transport.fetch)}>
|
||||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<FormPrompt form={form} />
|
<FormPrompt form={form} forms={coalesce ? [form, { ...form, id: "frm_other" }] : undefined} />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
@@ -126,3 +127,24 @@ test("includes external acknowledgements in progress", async () => {
|
|||||||
prompt.app.renderer.destroy()
|
prompt.app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("replies to every coalesced form", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const prompt = await mountForm(tmp.path, 80, true)
|
||||||
|
try {
|
||||||
|
prompt.app.mockInput.pressKey("right")
|
||||||
|
await prompt.app.waitForFrame((frame) => frame.includes("(acknowledgement required)"))
|
||||||
|
prompt.app.mockInput.pressEnter()
|
||||||
|
await prompt.app.waitForFrame((frame) => frame.includes("External action must be acknowledged"))
|
||||||
|
prompt.app.mockInput.pressKey("left")
|
||||||
|
prompt.app.mockInput.pressKey("c")
|
||||||
|
await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm"))
|
||||||
|
prompt.app.mockInput.pressEnter()
|
||||||
|
await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged"))
|
||||||
|
prompt.app.mockInput.pressEnter()
|
||||||
|
await prompt.app.waitFor(() => prompt.replies.length === 2)
|
||||||
|
expect(prompt.replies).toEqual([{ answer: { authorization: true } }, { answer: { authorization: true } }])
|
||||||
|
} finally {
|
||||||
|
prompt.app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { testRender } from "@opentui/solid"
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config"
|
import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config"
|
||||||
import { settings } from "../src/component/dialog-config"
|
|
||||||
|
|
||||||
test("validates mini replay settings", () => {
|
test("validates mini replay settings", () => {
|
||||||
const decode = Schema.decodeUnknownSync(Info)
|
const decode = Schema.decodeUnknownSync(Info)
|
||||||
@@ -18,10 +17,7 @@ test("validates mini replay settings", () => {
|
|||||||
test("validates the session tabs setting", () => {
|
test("validates the session tabs setting", () => {
|
||||||
const decode = Schema.decodeUnknownSync(Info)
|
const decode = Schema.decodeUnknownSync(Info)
|
||||||
|
|
||||||
expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({
|
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
|
||||||
tabs: { enabled: true, layout: "vertical" },
|
|
||||||
})
|
|
||||||
expect(() => decode({ tabs: { layout: true } })).toThrow()
|
|
||||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -42,13 +38,6 @@ test("resolves nested config and keybind defaults", () => {
|
|||||||
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
|
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
|
||||||
expect(config.diffs).toEqual({ view: "split" })
|
expect(config.diffs).toEqual({ view: "split" })
|
||||||
expect(config.debug).toEqual({ devtools: true })
|
expect(config.debug).toEqual({ devtools: true })
|
||||||
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shows resolved tab defaults in settings", () => {
|
|
||||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
|
|
||||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
|
|
||||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("provides config and its host interface", async () => {
|
test("provides config and its host interface", async () => {
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ async function renderSessionTabs(
|
|||||||
await Bun.write(
|
await Bun.write(
|
||||||
file,
|
file,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
global: { tabs: [], unread: {} },
|
global: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} },
|
||||||
cwd: { [directory]: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} } },
|
cwd: {},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -153,15 +153,15 @@ test("loads persisted tab metadata concurrently on connect", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("stores session tabs for the current working directory by default", async () => {
|
test("stores session tabs globally by default", async () => {
|
||||||
const setup = await renderSessionTabs("first")
|
const setup = await renderSessionTabs("first")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
||||||
await wait(() => Bun.file(file).size > 0)
|
await wait(() => Bun.file(file).size > 0)
|
||||||
expect(await Bun.file(file).json()).toEqual({
|
expect(await Bun.file(file).json()).toEqual({
|
||||||
global: { tabs: [], unread: {} },
|
global: { tabs: [{ sessionID: "first" }], unread: {} },
|
||||||
cwd: { [directory]: { tabs: [{ sessionID: "first" }], unread: {} } },
|
cwd: {},
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setup.destroy()
|
setup.destroy()
|
||||||
@@ -180,7 +180,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
|
|||||||
await titled.data.session.sync("shared")
|
await titled.data.session.sync("shared")
|
||||||
await wait(async () => {
|
await wait(async () => {
|
||||||
if (!(await Bun.file(file).exists())) return false
|
if (!(await Bun.file(file).exists())) return false
|
||||||
return (await Bun.file(file).json()).cwd[directory]?.tabs[0]?.title === "Generated title"
|
return (await Bun.file(file).json()).global.tabs[0]?.title === "Generated title"
|
||||||
})
|
})
|
||||||
const observed = ["Generated title"]
|
const observed = ["Generated title"]
|
||||||
const pending = new Set<Promise<void>>()
|
const pending = new Set<Promise<void>>()
|
||||||
@@ -189,7 +189,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
|
|||||||
const read = Bun.file(file)
|
const read = Bun.file(file)
|
||||||
.json()
|
.json()
|
||||||
.then((value) => {
|
.then((value) => {
|
||||||
const title = value.cwd[directory]?.tabs[0]?.title
|
const title = value.global.tabs[0]?.title
|
||||||
if (title && observed.at(-1) !== title) observed.push(title)
|
if (title && observed.at(-1) !== title) observed.push(title)
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|||||||
@@ -81,8 +81,9 @@ Add `compaction` to any [OpenCode configuration file](/config):
|
|||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"compaction": {
|
"compaction": {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
|
"prune": false,
|
||||||
"keep": {
|
"keep": {
|
||||||
"tokens": 15000
|
"tokens": 8000
|
||||||
},
|
},
|
||||||
"buffer": 20000
|
"buffer": 20000
|
||||||
}
|
}
|
||||||
@@ -92,7 +93,8 @@ Add `compaction` to any [OpenCode configuration file](/config):
|
|||||||
| Field | Default | V2 behavior |
|
| Field | Default | V2 behavior |
|
||||||
| --- | ---: | --- |
|
| --- | ---: | --- |
|
||||||
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
|
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
|
||||||
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
|
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
|
||||||
|
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
|
||||||
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
|
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
|
||||||
|
|
||||||
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
|
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
|
||||||
@@ -133,6 +135,8 @@ behavior.
|
|||||||
|
|
||||||
## Current limitations
|
## Current limitations
|
||||||
|
|
||||||
|
- `prune` is reserved configuration; V1-style in-place tool-output pruning is
|
||||||
|
not implemented in V2.
|
||||||
- Compaction requires a resolvable model with a positive catalog context limit.
|
- Compaction requires a resolvable model with a positive catalog context limit.
|
||||||
There is no separate compaction-model setting or fallback model.
|
There is no separate compaction-model setting or fallback model.
|
||||||
- Summary generation can fail if the summary prompt itself cannot fit beside
|
- Summary generation can fail if the summary prompt itself cannot fit beside
|
||||||
|
|||||||
+8
-16
@@ -246,27 +246,19 @@ Runtime hooks intercept live operations:
|
|||||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||||
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||||
| `ctx.session.hook("http.request", callback)` | `request`, immediately before provider dispatch |
|
| `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
|
||||||
| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds |
|
|
||||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
||||||
|
|
||||||
HTTP hooks can modify requests and responses. They apply to native models; AI
|
HTTP hooks can modify requests, inspect responses, retry, or return a
|
||||||
SDK models do not currently pass through these hooks. Request and response
|
response without calling the provider. It applies to native models; AI SDK
|
||||||
bodies are one-shot streams. Use `clone()` when you intentionally need a
|
models do not currently pass through this hook.
|
||||||
separate reader, but be aware that its slower branch may buffer data. To inspect
|
|
||||||
or modify chunks while preserving streaming, replace the body with one piped
|
|
||||||
through a `TransformStream`.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
await ctx.session.hook("http.request", (event) => {
|
await ctx.session.hook("http", (event) => {
|
||||||
event.request.headers.set("x-session-id", event.sessionID)
|
event.use((request, next) => {
|
||||||
})
|
request.headers.set("x-session-id", event.sessionID)
|
||||||
|
return next(request)
|
||||||
await ctx.session.hook("http.response", (event) => {
|
|
||||||
event.response = new Response(event.response.body, {
|
|
||||||
status: event.response.status,
|
|
||||||
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" },
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user