Compare commits

..
Author SHA1 Message Date
Kit Langton 583103f2dd test(core): limit process group survival check to POSIX
Node assigns non-detached Windows children to a kill-on-parent-exit job, so the held-stdio mcp fixture cannot assert POSIX process-group survival there. Keep detached descendant, capture deadline, and success-policy coverage enabled on Windows.
2026-08-31 15:35:32 -04:00
Kit Langton acaca2cc01 fix(util): separate process exit from capture completion
Report exit and running state from the child exit signal, independently of buffered output. On scope release, discard abandoned capture and retain the existing pipe-close or capture-deadline wait before applying process-group cleanup policy.

Cover unread output after confirmed process exit, successful descendant survival, and the approved policy that a parent exiting successfully before its invocation timeout retains success while the bounded capture grace finishes.
2026-08-31 15:12:01 -04:00
Kit Langton ef8b8c1b8d fix(util): preserve process output for late readers
Buffer child stdout and stderr before Effect consumers attach, retaining stream backpressure and scoped cleanup. Detach capture buffers before the existing post-exit discard deadline drains inherited pipes.

Cover post-exit readers, output larger than the buffers, and teardown with unread stdout through the real process spawner.
2026-08-31 14:55:04 -04:00
326 changed files with 4535 additions and 15500 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
- The default branch in this repo is `v2`.
- Default new branches and worktrees to `v2`, or `origin/v2` when the local `v2` ref is unavailable, and default pull requests to target `v2`. Use another base or target branch when the requester explicitly instructs it.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing
+13 -3
View File
@@ -969,7 +969,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -979,7 +978,6 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
@@ -3213,6 +3211,8 @@
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -5451,6 +5451,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
@@ -5665,6 +5667,8 @@
"uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="],
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -5813,7 +5817,9 @@
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
@@ -5845,6 +5851,8 @@
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
@@ -6599,6 +6607,8 @@
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
"x86_64-linux": "sha256-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
}
}
+2 -2
View File
@@ -5,10 +5,10 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.0",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+46 -64
View File
@@ -176,14 +176,14 @@ export const InputItem = Schema.Union([
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type HostedToolReplayItem = {
export type ExtendedHostedToolItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| ExtendedHostedToolItem
| {
readonly type: "message"
readonly id?: string
@@ -373,7 +373,7 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
export interface ProviderAdapter {
export interface Extension {
readonly id: string
readonly name: string
readonly lowerMedia?: (input: {
@@ -381,10 +381,10 @@ export interface ProviderAdapter {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
}
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
const BASE: Extension = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly id: string
@@ -397,9 +397,6 @@ export interface ParserState {
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -485,12 +482,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -510,17 +507,17 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const lowerUserContent = Effect.fnUntraced(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
const lowered = yield* lowerMedia(part, request, adapter, "message")
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return lowered
})
@@ -529,13 +526,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
const lowerToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
adapter,
extension,
"tool-result",
)
})
@@ -543,33 +540,30 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
adapter,
extension,
)
})
const lowerToolResultOutput = Effect.fnUntraced(function* (
part: ToolResultPart,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Content> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -577,13 +571,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (message.role === "system") {
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
})
continue
}
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
if (content.length > 0) input.push({ role: "user", content })
continue
}
@@ -650,7 +644,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: adapter.restoreHostedToolItem?.(part.result.value)
: extension.lowerHostedToolItem?.(part.result.value)
if (id !== undefined && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
@@ -664,11 +658,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
continue
}
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
"text",
"reasoning",
"tool-call",
@@ -681,11 +677,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, adapter),
output: yield* lowerToolResultOutput(part, request, extension),
})
}
}
@@ -737,28 +733,28 @@ const allowedToolChoice = (request: LLMRequest) => {
}
}
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, adapter),
input: yield* lowerMessages(request, extension),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(
adapter.name,
extension.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -772,7 +768,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
})
// =============================================================================
@@ -955,16 +951,12 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -977,7 +969,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
{
...state,
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
@@ -1094,12 +1085,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const message = state.message?.id === item.id ? state.message : undefined
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
@@ -1112,13 +1098,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
const lifecycle =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
completedMessages,
message: undefined,
message: message ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1257,12 +1243,9 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
if (
item.type !== "function_call" ||
!item.call_id ||
!Object.values(current.tools).some((tool) => tool?.id === item.call_id)
)
continue
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (id === undefined) continue
if (item.type !== "function_call" || !current.tools[id]) continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
@@ -1425,9 +1408,9 @@ export const step = (state: ParserState, input: Event) => {
* The provider-neutral Open Responses protocol. Provider-specific Responses
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
id: adapter.id,
name: adapter.name,
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
id: extension.id,
name: extension.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
@@ -1435,7 +1418,6 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -86,11 +86,11 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const adapter = {
const extension = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
@@ -125,9 +125,9 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithAdapter(
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
adapter,
extension,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
@@ -204,7 +204,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, adapter),
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
+5 -5
View File
@@ -36,15 +36,15 @@ const XAIResponsesBody = Schema.Struct({
stream: Schema.Literal(true),
})
const adapter = {
const extension = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
})
const HOSTED_TOOLS = {
@@ -78,7 +78,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, adapter),
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
@@ -82,32 +82,6 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -216,63 +216,7 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
Effect.gen(function* () {
const text = {
type: "message",
id: "msg_text",
content: [{ type: "output_text", text: "Done-only text." }],
}
const refusal = {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_text",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-end",
id: "msg_text",
text: "Done-only text.",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-start",
id: "msg_refusal",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
{
type: "text-end",
id: "msg_refusal",
text: "Done-only refusal.",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
])
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
@@ -289,44 +233,9 @@ describe("Open Responses basic-item lifecycles", () => {
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
}),
)
;[undefined, "fc_1"].forEach((id) => {
@@ -4017,7 +4017,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
it.effect("uses completed response output when output item completion is missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4032,6 +4032,7 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -1,16 +1,5 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
})
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
const component = await mount(`opencode-composer-flow--${draft}`)
@@ -123,7 +123,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessionID, tabKey }) => {
({ directory, server, sessionID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -131,8 +131,10 @@ async function setup(page: Page) {
lastProject: { local: directory },
}),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -142,6 +144,6 @@ async function setup(page: Page) {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
{ directory, server, sessionID },
)
}
@@ -10,7 +10,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
@@ -21,7 +21,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
if (dismissal === "drag") {
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
const bounds = await handle.boundingBox()
expect(bounds).not.toBeNull()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
@@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
})
await page.addInitScript(
({ directory, server, sessionID, tabKey }) => {
({ directory, server, sessionID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -88,8 +88,10 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
lastProject: { local: directory },
}),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -99,7 +101,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -64,7 +64,7 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", target: id },
source: { type: "package", package: id },
state: { status: "active" },
features: { server: true },
})),
@@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => {
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID, tabKey }) => {
({ directory, server, sessionID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -69,8 +69,10 @@ test("opens and searches project files inline", async ({ page }) => {
lastProject: { local: directory },
}),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
@@ -80,7 +82,7 @@ test("opens and searches project files inline", async ({ page }) => {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -22,7 +22,6 @@ test("restores review mode and selected file per session", async ({ page }) => {
await selectFile(page, "alpha.ts")
await switchSession(page, titleB)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
@@ -18,72 +18,37 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// The review pane's data is workspace-scoped, but visibility belongs to each
// session tab. Switching tabs must keep the pane mounted without opening it.
test("keeps review visibility per tab and the pane mounted across tab switches", async ({ page }) => {
// The v2 review pane's diff data is workspace-scoped: switching between session
// tabs in the same workspace must update its parameters reactively instead of
// tearing the pane down and remounting it (which flickers).
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toContain("0.24s")
const reviewTab = page.locator("#session-side-panel-review-tab")
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
const chatPanel = page.locator('[data-slot="session-chat-panel"]')
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]')
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await writeProbe(page)
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expect(review).toBeHidden()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toBe("0s")
expect(await readProbe(page)).toBe(PROBE)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "520px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
await expect
.poll(() =>
page.evaluate(
({ key }) => {
const panes = JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")
return panes[key]?.review
},
{ key: `${server}\n${sessionHref(sessionA)}` },
),
)
.toBe(true)
await page.reload()
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
@@ -137,7 +102,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions, panes }) => {
({ directory, server, sessions }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -149,19 +114,8 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { sessionWidth: 580 },
[`${server}\n${sessionHref(sessionB)}`]: { sessionWidth: 520 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
@@ -123,17 +123,12 @@ test("uses side placement by default and supports the terminal across the bottom
}),
)
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(
({ tabKey, server, sessionID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`, server, sessionID },
)
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionReady(page, { server, sessionID, title })
@@ -85,7 +85,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
data: [
{
id: "demo-plugin",
source: { type: "package", target: "demo-plugin" },
source: { type: "package", package: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
@@ -252,15 +252,8 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
function seedCachedTerminal(page: Page) {
return page.addInitScript(
({ terminalKey, ptyID, tabKey, server, sessionID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs.panes",
JSON.stringify({ [tabKey]: { terminal: true, terminalHeight: 320 } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
({ terminalKey, ptyID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
localStorage.setItem(
terminalKey,
JSON.stringify({
@@ -269,13 +262,7 @@ function seedCachedTerminal(page: Page) {
}),
)
},
{
terminalKey: terminalStorageKey(),
ptyID,
tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`,
server,
sessionID,
},
{ terminalKey: terminalStorageKey(), ptyID },
)
}
@@ -16,9 +16,10 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// Terminal processes are workspace-scoped, but panel visibility belongs to each
// session tab. Switching tabs must keep the PTY alive without opening its panel.
test("keeps terminal visibility per tab and the PTY alive across tab switches", async ({ page }) => {
// Terminals are workspace-scoped: switching between session tabs in the same
// workspace must keep the terminal mounted and its PTY connection open instead
// of tearing it down and reconnecting.
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
const connections = await setup(page)
await page.goto(sessionHref(sessionA))
@@ -26,10 +27,7 @@ test("keeps terminal visibility per tab and the PTY alive across tab switches",
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
const terminalPanel = page.locator('[data-component="terminal-panel"]')
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "true")
await expect(terminalPanel).toHaveCSS("height", "300px")
await expect.poll(() => connections.length).toBe(1)
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
@@ -39,26 +37,15 @@ test("keeps terminal visibility per tab and the PTY alive across tab switches",
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expect(terminal).toBeHidden()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "false")
await expect(terminal).toBeVisible()
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "180px")
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.reload()
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
})
type Probed = HTMLElement & { __e2eProbe?: string }
@@ -133,7 +120,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions, panes }) => {
({ directory, server, sessions }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -145,20 +132,8 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { terminalHeight: 300 },
[`${server}\n${sessionHref(sessionB)}`]: { terminalHeight: 180 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
return connections
}
@@ -75,12 +75,11 @@ for (const theme of ["light", "dark"] as const) {
await expectBackground(view.send, "contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectToken(
await expectBackground(
message,
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
"background-color",
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
)
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
})
}
@@ -246,16 +245,3 @@ async function expectBackground(element: Locator, token: string, property = "bac
}, token)
await expect(element).toHaveCSS(property, new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
}
async function expectToken(element: Locator, property: string, token: string) {
const color = await element.evaluate((element, token) => {
const probe = document.createElement("span")
probe.hidden = true
probe.style.color = `var(${token})`
element.append(probe)
const color = getComputedStyle(probe).color
probe.remove()
return color
}, token)
await expect(element).toHaveCSS(property, color)
}
@@ -1,7 +1,3 @@
[data-component="composer-editor"]:empty::before {
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
background: var(--v2-background-bg-layer-01);
}
+3 -1
View File
@@ -114,8 +114,10 @@ export function ComposerEditor(props: ComposerEditorProps) {
<form
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
classList={{
"bg-v2-background-bg-layer-01": props.borderUnderlay,
"bg-v2-background-bg-base": !props.borderUnderlay,
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
@@ -8,7 +8,7 @@ describe("pluginLabels", () => {
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", target: "example" },
source: { type: "package", package: "example" },
state: { status: "active" },
features: { server: true },
},
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+10 -19
View File
@@ -1,25 +1,16 @@
import { createMemo, type Accessor } from "solid-js"
import createPresence from "solid-presence"
export function createAnimatedPresence<T>(
value: Accessor<T | undefined>,
element: Accessor<HTMLElement | null>,
identity?: Accessor<unknown>,
) {
const animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>(
(previous) => {
const currentIdentity = identity?.()
const current = value()
const show = current !== undefined
const same = !identity || previous?.identity === currentIdentity
return {
identity: currentIdentity,
show,
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
value: current ?? (same ? previous?.value : undefined),
}
},
)
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
const current = value()
const show = current !== undefined
return {
show,
animate: previous !== undefined && (previous.animate || previous.show !== show),
value: current ?? previous?.value,
}
})
const presence = createPresence({ show: () => animation().show, element })
return {
...presence,
@@ -155,16 +155,6 @@ describe("isSessionNotFoundError", () => {
expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), body.sessionID)).toBe(true)
})
test("matches a structured error stored directly as the cause", () => {
const body = {
_tag: "SessionNotFoundError",
sessionID: "ses_missing",
message: "Session not found",
} satisfies SessionNotFoundError
expect(isSessionNotFoundError(new Error("Unknown error", { cause: body }), body.sessionID)).toBe(true)
})
test("rejects errors for other sessions and other 404 responses", () => {
const body = {
_tag: "SessionNotFoundError",
+4 -3
View File
@@ -36,9 +36,10 @@ export function formatServerError(error: unknown, translate?: Translator, fallba
}
function unwrapNamedError(error: unknown): unknown {
if (!(error instanceof Error) || !error.cause || typeof error.cause !== "object") return error
if ("body" in error.cause) return (error.cause as Record<string, unknown>).body
return error.cause
if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) {
return (error.cause as Record<string, unknown>).body
}
return error
}
// Client-synthesized session not-found errors share one constructor and
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2, sortFileTreeV2Paths } from "./file-tree-v2-model"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
import type { FileNode } from "@/runtime/server/types"
describe("buildFileTreeV2Model", () => {
@@ -45,33 +45,6 @@ describe("buildFileTreeV2Model", () => {
})
})
describe("sortFileTreeV2Paths", () => {
test("orders navigation depth-first with directories before sibling files", () => {
const paths = ["README.md", "src/a.ts", "src/lib/z.ts", "docs/guide.md", "src/lib/b.ts"]
expect(sortFileTreeV2Paths(paths)).toEqual([
"docs/guide.md",
"src/lib/b.ts",
"src/lib/z.ts",
"src/a.ts",
"README.md",
])
expect(paths[0]).toBe("README.md")
})
test("preserves original paths for selection", () => {
expect(sortFileTreeV2Paths(["README.md", "src\\lib\\a.ts", "/docs//guide.md/"])).toEqual([
"/docs//guide.md/",
"src\\lib\\a.ts",
"README.md",
])
})
test("handles an empty file list", () => {
expect(sortFileTreeV2Paths([])).toEqual([])
})
})
describe("flattenLiveFileTreeV2", () => {
test("flattens live children using original paths for nested lookups", () => {
const nodes: Record<string, FileNode[]> = {
@@ -76,12 +76,6 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
return rows
}
export function sortFileTreeV2Paths(paths: readonly string[]) {
return flattenFileTreeV2(buildFileTreeV2Model(paths), () => true)
.filter((row) => row.node.type === "file")
.map((row) => row.node.originalPath)
}
export function flattenLiveFileTreeV2(
children: (path: string) => readonly FileNode[],
expanded: (path: string) => boolean,
-1
View File
@@ -129,7 +129,6 @@ export function useSessionModel() {
layout: {
tabs: layout.tabs,
view: layout.view,
tabKey: layout.tabKey,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
+2 -6
View File
@@ -18,7 +18,6 @@ import type {
SessionReviewLineComment,
} from "@opencode-ai/session-ui/session-review"
import FileTreeV2 from "@/session/files/file-tree-v2"
import { sortFileTreeV2Paths } from "@/session/files/file-tree-v2-model"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
@@ -84,9 +83,6 @@ export function ReviewPanelView(
),
)
const searching = createMemo(() => props.state.filter().trim().length > 0)
const navigationFiles = createMemo(() =>
searching() || props.fileList === "flat" ? filteredFiles() : sortFileTreeV2Paths(filteredFiles()),
)
const kinds = createMemo(() => reviewDiffKinds(diffs()))
// Changes-only trees omit "M" — every row is already a change; A/D stay visible.
const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
@@ -97,7 +93,7 @@ export function ReviewPanelView(
if (focus && diffs().some((diff) => diff.file === focus.file)) return focus.file
const active = props.activeFile
if (searching()) return active
const files = navigationFiles()
const files = filteredFiles()
if (active && files.includes(active)) return active
return files[0]
})
@@ -145,7 +141,7 @@ export function ReviewPanelView(
/>
}
activeFile={activeDiff()}
files={navigationFiles()}
files={filteredFiles()}
onSelectFile={props.onSelectFile}
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
+2 -3
View File
@@ -12,7 +12,6 @@ export function createSessionScreenLayout(session: SessionModel) {
const layout = useLayout()
const settings = useSettings()
const size = createSizing()
const view = session.layout.view
const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened())
const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id)
const terminalOpen = createMemo(() => session.layout.view().terminal.opened())
@@ -43,7 +42,7 @@ export function createSessionScreenLayout(session: SessionModel) {
const splitReview = createMemo(() => reviewPanelOpen() && layout.review.diffStyle() === "split")
const resizedWidth = createMemo(() =>
clampSessionPanelWidth({
width: view().reviewPanel.width(),
width: layout.session.width(),
available: available(),
split: splitReview(),
}),
@@ -73,7 +72,7 @@ export function createSessionScreenLayout(session: SessionModel) {
}, panelLayout().stacked)
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
const terminalPane = createMemo(() =>
Math.min(view().terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
)
const terminalPaneHeight = createMemo(() => `${terminalPane()}px`)
const sideHeight = createMemo(() => rowSize.height)
+26 -57
View File
@@ -11,8 +11,10 @@ import {
on,
} from "solid-js"
import { createStore } from "solid-js/store"
import createPresence from "solid-presence"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { SessionHeader } from "@/session/header/session-header"
import { useLayout } from "@/shell/state/layout"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
@@ -29,7 +31,6 @@ import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { createAnimatedPresence } from "@/runtime/animated-presence"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -38,6 +39,7 @@ const SessionMobileFiles = lazy(async () => {
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const layout = useLayout()
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
@@ -64,40 +66,14 @@ export function SessionScreen(props: { session: SessionModel }) {
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom())
const sidePresence = createAnimatedPresence(
() => sideVisible() || undefined,
() => elements.side ?? null,
session.layout.tabKey,
)
const bottomTerminalPresence = createAnimatedPresence(
() => bottomTerminalVisible() || undefined,
() => elements.bottomTerminal ?? null,
session.layout.tabKey,
)
const sideMotion = createMemo<{
key?: string
region: boolean
terminal: boolean
animateRegion: boolean
animateTerminal: boolean
}>((previous) => {
const key = session.layout.tabKey()
const region = screen.side.region.open()
const terminal = sideTerminalVisible()
const sameTab = previous?.key === key
return {
key,
region,
terminal,
animateRegion: !!previous && sameTab && previous.region !== region,
animateTerminal: !!previous && sameTab && previous.terminal !== terminal,
}
const sidePresence = createPresence({
show: sideVisible,
element: () => elements.side ?? null,
})
const bottomTerminalPresence = createPresence({
show: bottomTerminalVisible,
element: () => elements.bottomTerminal ?? null,
})
const paneAnimating = () =>
sidePresence.animate() ||
sideMotion().animateRegion ||
sideMotion().animateTerminal ||
bottomTerminalPresence.animate()
createEffect(() => {
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
@@ -280,8 +256,7 @@ export function SessionScreen(props: { session: SessionModel }) {
classList={{
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!screen.size.active() && sidePresence.animate(),
"transition-none": screen.size.active() || !sidePresence.animate(),
!screen.size.active(),
}}
data-slot="session-chat-panel"
style={{
@@ -304,18 +279,18 @@ export function SessionScreen(props: { session: SessionModel }) {
max={screen.panel.max()}
onResize={(width) => {
screen.size.touch()
session.layout.view().reviewPanel.resize(width)
layout.session.resize(width)
}}
/>
</div>
</Show>
</div>
<Show when={sidePresence.present() || store.sideReviewPresent || store.sideTerminalPresent}>
<Show when={sidePresence.present() || store.sideTerminalPresent}>
<div
ref={(element) => setElements("side", element)}
data-slot="session-side-panel-presence"
data-opened={sidePresence.animate() ? sidePresence.show() : undefined}
data-opened={sideVisible()}
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
@@ -336,15 +311,15 @@ export function SessionScreen(props: { session: SessionModel }) {
data-slot="session-side-region"
classList={{
"absolute inset-x-0 top-0 min-h-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
"transition-none": screen.size.active() || !store.sideHeightMotion,
}}
style={{ height: screen.side.region.height() }}
>
<Show when={store.sideRegionPresent}>
<div
data-slot="session-side-region-presence"
data-opened={sideMotion().animateRegion ? sideMotion().region : undefined}
data-opened={screen.side.region.open()}
class="absolute inset-0"
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
@@ -366,7 +341,6 @@ export function SessionScreen(props: { session: SessionModel }) {
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"delay-0": !screen.side.gap.closing(),
"delay-[200ms]": screen.side.gap.closing(),
"transition-none": !paneAnimating(),
}}
style={{ height: screen.side.gap.height() }}
onPointerDown={() => screen.size.start()}
@@ -375,13 +349,13 @@ export function SessionScreen(props: { session: SessionModel }) {
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={session.layout.view().terminal.height()}
size={layout.terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
session.layout.view().terminal.resize(height)
layout.terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
@@ -391,15 +365,15 @@ export function SessionScreen(props: { session: SessionModel }) {
data-slot="session-side-terminal-region"
classList={{
"relative z-10 min-h-0 shrink-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
"transition-none": screen.size.active() || !store.sideHeightMotion,
}}
style={{ height: screen.side.terminal.height() }}
>
<Show when={store.sideTerminalPresent}>
<div
data-slot="side-terminal-panel-presence"
data-opened={sideMotion().animateTerminal ? sideMotion().terminal : undefined}
data-opened={sideTerminalVisible()}
class="absolute inset-0 rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<div data-slot="side-terminal-panel-clip" class="size-full overflow-clip rounded-[10px]">
@@ -407,7 +381,6 @@ export function SessionScreen(props: { session: SessionModel }) {
fill
framed={false}
present={store.sideTerminalPresent}
animate={sidePresence.animate() || sideMotion().animateTerminal}
contentHeight={screen.side.terminal.contentHeight()}
/>
</div>
@@ -424,7 +397,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<div
ref={(element) => setElements("bottomTerminal", element)}
data-slot="terminal-panel-presence"
data-opened={bottomTerminalPresence.animate() ? bottomTerminalPresence.show() : undefined}
data-opened={bottomTerminalVisible()}
classList={{
hidden: !bottomTerminalPresence.present(),
"relative min-h-0 shrink-0": isDesktop(),
@@ -435,23 +408,19 @@ export function SessionScreen(props: { session: SessionModel }) {
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={session.layout.view().terminal.height()}
size={layout.terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
session.layout.view().terminal.resize(height)
layout.terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
</div>
</Show>
<TerminalPanel
stacked={isDesktop()}
present={store.bottomTerminalCached}
animate={bottomTerminalPresence.animate()}
/>
<TerminalPanel stacked={isDesktop()} present={store.bottomTerminalCached} />
</div>
</Show>
</div>
+1 -23
View File
@@ -5,8 +5,6 @@ import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { base64Encode } from "@opencode-ai/util/encode"
import { ServerConnection } from "@/runtime/server/registry"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
export const useSessionKey = () => {
const params = useParams()
@@ -21,32 +19,12 @@ export const useSessionKey = () => {
export const useSessionLayout = () => {
const layout = useLayout()
const tabs = useTabs()
const { params, sessionKey, workspaceKey } = useSessionKey()
const serverSDK = useServerSDK()
const currentTab = createMemo(() => {
if (!params.id) return
return findSessionTab(tabs.store, ServerConnection.key(serverSDK.server), params.id)
})
const panes = {
terminalOpened: () => tabs.pane(currentTab(), "terminal"),
setTerminalOpened: (opened: boolean) => tabs.setPane(currentTab(), "terminal", opened),
terminalHeight: () => tabs.paneSize(currentTab(), "terminalHeight"),
setTerminalHeight: (height: number) => tabs.setPaneSize(currentTab(), "terminalHeight", height),
reviewOpened: () => tabs.pane(currentTab(), "review"),
setReviewOpened: (opened: boolean) => tabs.setPane(currentTab(), "review", opened),
sessionWidth: () => tabs.paneSize(currentTab(), "sessionWidth"),
setSessionWidth: (width: number) => tabs.setPaneSize(currentTab(), "sessionWidth", width),
}
return {
params,
sessionKey,
workspaceKey,
tabKey: createMemo(() => {
const tab = currentTab()
return tab && tabKey(tab)
}),
tabs: createMemo(() => layout.tabs(sessionKey)),
view: createMemo(() => layout.view(sessionKey, panes)),
view: createMemo(() => layout.view(sessionKey)),
}
}
+4 -4
View File
@@ -17,6 +17,7 @@ import { SortableTerminalTab } from "@/session/terminal/tab"
import { Terminal } from "@/session/terminal/terminal"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useTerminal, type LocalPTY } from "@/session/terminal/context"
import { useWorkspaceLocation } from "@/workspaces/location"
import { terminalTabLabel } from "@/session/terminal/terminal-label"
@@ -44,9 +45,9 @@ export function TerminalPanel(
present?: boolean
contentHeight?: string
embedded?: boolean
animate?: boolean
} = {},
) {
const layout = useLayout()
const terminal = useTerminal()
const sdk = useWorkspaceLocation()
const language = useLanguage()
@@ -56,7 +57,7 @@ export function TerminalPanel(
const isDesktop = createMediaQuery("(min-width: 768px)")
const opened = createMemo(() => view().terminal.opened())
const size = createSizing()
const height = createMemo(() => view().terminal.height())
const height = createMemo(() => layout.terminal.height())
const close = () => view().terminal.close()
let root: HTMLElement | undefined
let tabList: HTMLDivElement | undefined
@@ -237,11 +238,10 @@ export function TerminalPanel(
pane={pane()}
max={max()}
resizing={size.active()}
animate={props.animate}
onResizeStart={size.start}
onResize={(next) => {
size.touch()
view().terminal.resize(next)
layout.terminal.resize(next)
}}
onCollapse={close}
>
@@ -15,7 +15,6 @@ export function TerminalSurface(
pane: number
max: number
resizing: boolean
animate?: boolean
onResizeStart: () => void
onResize: (height: number) => void
onCollapse: () => void
@@ -28,9 +27,7 @@ export function TerminalSurface(
id="terminal-panel"
data-component="terminal-panel"
data-opened={props.opened}
data-size-animated={
props.animate !== false && !props.embedded && !props.resizing && (!props.desktop || props.stacked)
}
data-size-animated={!props.embedded && !props.resizing && (!props.desktop || props.stacked)}
role="region"
aria-label={props.label}
aria-hidden={!props.opened}
@@ -478,11 +478,7 @@ export function createTimelineVirtualizer(input: Input) {
}
return (
<div
class="relative w-full h-full min-w-0"
data-workspace-session={props.workspaceSession() ? "" : undefined}
data-local-session={!props.workspaceSession() ? "" : undefined}
>
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
-90
View File
@@ -1,90 +0,0 @@
[data-slot="mobile-drawer-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-drawer-backdrop-in 240ms ease-out;
}
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-drawer-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-drawer-content"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-drawer-content"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-drawer-content"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-drawer-content"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-drawer-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-drawer-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
@keyframes mobile-drawer-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-drawer-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-drawer-content"][data-transitioning],
[data-slot="mobile-drawer-content"][data-closing] {
transition: none;
}
[data-slot="mobile-drawer-overlay"],
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
-47
View File
@@ -1,47 +0,0 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import "./mobile-drawer.css"
export function MobileDrawer(
props: ParentProps<{
open: boolean
onOpenChange: (open: boolean) => void
onContentPresentChange?: (present: boolean) => void
returnFocus?: () => HTMLElement | undefined
closeOnOutsideFocus?: boolean
}>,
) {
return (
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
onContentPresentChange={props.onContentPresentChange}
side="bottom"
finalFocusEl={props.returnFocus?.()}
closeOnOutsideFocus={props.closeOnOutsideFocus}
>
{props.children}
</Drawer>
)
}
export const MobileDrawerTrigger = Drawer.Trigger
export function MobileDrawerContent(props: ParentProps) {
const language = useLanguage()
return (
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-drawer-overlay" />
<Drawer.Content forceMount data-slot="mobile-drawer-content" dir={language.direction()}>
<div data-slot="mobile-drawer-handle" aria-hidden="true">
<span />
</div>
{props.children}
</Drawer.Content>
</Drawer.Portal>
)
}
export const MobileDrawerLabel = Drawer.Label
export const MobileDrawerClose = Drawer.Close
@@ -1,34 +0,0 @@
[data-slot="mobile-panel"] {
display: flex;
min-height: 0;
flex-direction: column;
}
[data-slot="mobile-panel-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-panel-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-panel-close"][data-component="button-v2"] {
height: 44px;
flex-shrink: 0;
}
[data-slot="mobile-panel-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
+23 -21
View File
@@ -1,8 +1,7 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
import "./mobile-panel-drawer.css"
import "./status/status-drawer.css"
export function MobilePanelDrawer(
props: ParentProps<{
@@ -14,29 +13,32 @@ export function MobilePanelDrawer(
) {
const language = useLanguage()
return (
<MobileDrawer
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
returnFocus={props.returnFocus}
side="bottom"
finalFocusEl={props.returnFocus?.()}
// Menu focus handoff must not dismiss the drawer during its opening transition.
closeOnOutsideFocus={false}
>
<MobileDrawerContent>
<div data-slot="mobile-panel" data-corvu-no-drag>
<div data-slot="mobile-panel-header">
<MobileDrawerLabel>{props.title}</MobileDrawerLabel>
<MobileDrawerClose
as={Button}
variant="ghost"
data-slot="mobile-panel-close"
aria-label={language.t("common.close")}
>
{language.t("common.close")}
</MobileDrawerClose>
{/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-status-overlay" />
<Drawer.Content forceMount data-slot="mobile-status-drawer" dir={language.direction()}>
<div data-slot="mobile-status-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-panel-content">{props.children}</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
<div data-slot="mobile-status-header" data-corvu-no-drag>
<Drawer.Label>{props.title}</Drawer.Label>
<Drawer.Close data-slot="mobile-status-close" aria-label={language.t("common.close")}>
{language.t("common.close")}
</Drawer.Close>
</div>
<div data-slot="mobile-status-content" data-corvu-no-drag>
{props.children}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
)
}
+3 -49
View File
@@ -59,16 +59,6 @@ export type HomeProjectSelection = { server: ServerConnection.Key; directory?: s
export type ReviewDiffStyle = "unified" | "split"
export type ReviewChangeMode = "git" | "branch" | "turn"
export type ReviewPanelSource = "context-button" | "other"
export type TabPanes = {
terminalOpened: Accessor<boolean>
setTerminalOpened(opened: boolean): void
terminalHeight: Accessor<number | undefined>
setTerminalHeight(height: number): void
reviewOpened: Accessor<boolean>
setReviewOpened(opened: boolean): void
sessionWidth: Accessor<number | undefined>
setSessionWidth(width: number): void
}
export type LayoutRoute =
| { type: "home" }
@@ -518,7 +508,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return message
},
},
view(sessionKey: string | Accessor<string>, panes?: TabPanes) {
view(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewMode = createMemo(() => {
@@ -529,24 +519,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const file = s().reviewFile
if (typeof file === "string") return file
})
const terminalOpened = panes?.terminalOpened ?? createMemo(() => store.terminal?.opened ?? false)
const terminalHeight = createMemo(() =>
panes
? (panes.terminalHeight() ?? DEFAULT_TERMINAL_HEIGHT)
: (store.terminal?.height ?? DEFAULT_TERMINAL_HEIGHT),
)
const reviewPanelOpened =
panes?.reviewOpened ?? createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const sessionWidth = createMemo(() =>
panes ? (panes.sessionWidth() ?? DEFAULT_SESSION_WIDTH) : store.session.width,
)
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
function setTerminalOpened(next: boolean) {
if (panes) {
panes.setTerminalOpened(next)
return
}
const current = store.terminal
if (!current) {
setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
@@ -560,13 +537,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
const nextSource = next ? source : "other"
if (panes) {
batch(() => {
panes.setReviewOpened(next)
setEphemeral("reviewPanelSource", nextSource)
})
return
}
const current = store.review
if (!current) {
batch(() => {
@@ -596,14 +566,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
terminal: {
opened: terminalOpened,
height: terminalHeight,
resize(height: number) {
if (panes) {
panes.setTerminalHeight(height)
return
}
setStore("terminal", "height", height)
},
open() {
setTerminalOpened(true)
},
@@ -617,14 +579,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
reviewPanel: {
opened: reviewPanelOpened,
source: reviewPanelSource,
width: sessionWidth,
resize(width: number) {
if (panes) {
panes.setSessionWidth(width)
return
}
setStore("session", "width", width)
},
open(source: ReviewPanelSource = "other") {
setReviewPanelOpened(true, source)
},
@@ -1,3 +1,109 @@
[data-slot="mobile-status-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-status-backdrop-in 240ms ease-out;
}
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-status-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-status-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-status-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-status-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-status-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-status-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-status-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-status-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-status-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-status-close"] {
min-height: 44px;
flex-shrink: 0;
padding-inline: 12px;
border-radius: 6px;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
@media (hover: hover) {
[data-slot="mobile-status-close"]:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
}
[data-slot="mobile-status-close"]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: -2px;
}
[data-slot="mobile-status-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
[data-slot="mobile-status-loading"] {
display: flex;
min-height: 56px;
@@ -7,3 +113,33 @@
font-size: 13px;
line-height: var(--line-height-base);
}
@keyframes mobile-status-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-status-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-status-drawer"][data-transitioning],
[data-slot="mobile-status-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-status-overlay"],
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
@@ -1,7 +1,6 @@
import { lazy, Suspense } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { MobilePanelDrawer } from "../mobile-panel-drawer"
import "./status-drawer.css"
const Body = lazy(async () => {
const { StatusPopoverBody } = await import("./body")
+2 -6
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
import type { ServerConnection } from "@/runtime/server/registry"
@@ -48,12 +48,8 @@ test("session tab identity stays rooted while its href follows the child route",
})
test("finds open root and routed session tabs", () => {
const tab = { ...sessionTab("root"), routeSessionId: "child" }
const tabs = [tab]
const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }]
expect(findSessionTab(tabs, server, "root")).toBe(tab)
expect(findSessionTab(tabs, server, "child")).toBe(tab)
expect(findSessionTab(tabs, server, "closed")).toBeUndefined()
expect(sessionIDHasOpenTab(tabs, server, "root")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "child")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "closed")).toBe(false)
+3 -60
View File
@@ -46,10 +46,6 @@ export type TabInfo = {
directory?: string
}
export type TabPane = "terminal" | "review"
export type TabPaneSize = "terminalHeight" | "sessionWidth"
type TabPaneState = Partial<Record<TabPane, boolean> & Record<TabPaneSize, number>>
type RecentTab = {
key?: string
}
@@ -66,8 +62,8 @@ export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, ses
return sessionIDHasOpenTab(tabs, server, session.id)
}
export function findSessionTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.find(
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.some(
(tab) =>
tab.type === "session" &&
tab.server === server &&
@@ -75,10 +71,6 @@ export function findSessionTab(tabs: Tab[], server: ServerConnection.Key, sessio
)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return !!findSessionTab(tabs, server, sessionID)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
@@ -97,10 +89,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
Persist.window("tabs.info"),
createStore<Record<string, TabInfo>>({}),
)
const [panes, setPanes, , panesReady] = persisted(
Persist.window("tabs.panes"),
createStore<Record<string, TabPaneState>>({}),
)
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
@@ -153,15 +141,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
}
const removePanes = (key: string) => {
if (!panes[key]) return
setPanes(
produce((draft) => {
delete draft[key]
}),
)
}
onCleanup(memory.dispose)
createEffect(() => {
@@ -174,7 +153,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const key = tabKey(tab)
memory.remove(key)
removeInfo(key)
removePanes(key)
}
}
setStore(() => next)
@@ -184,10 +162,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of Object.keys(info)) {
if (!keys.has(key)) removeInfo(key)
}
if (!panesReady()) return
for (const key of Object.keys(panes)) {
if (!keys.has(key)) removePanes(key)
}
})
createEffect(() => {
@@ -224,7 +198,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}).finally(() => closing.delete(key))
memory.remove(key)
removeInfo(key)
removePanes(key)
if (draftID) removeDraftPersisted(draftID)
}
@@ -518,38 +491,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
stateValue<T>(tab: Tab, name: string) {
return memory.get<T>(tabKey(tab), name)
},
pane(tab: Tab | undefined, pane: TabPane) {
if (!tab) return false
return panes[tabKey(tab)]?.[pane] ?? false
},
setPane(tab: Tab | undefined, pane: TabPane, opened: boolean) {
if (!tab) return
const key = tabKey(tab)
const current = panes[key]
if (current?.[pane] === opened) return
if (!current) {
setPanes(key, { [pane]: opened })
return
}
setPanes(key, pane, opened)
},
paneSize(tab: Tab | undefined, size: TabPaneSize) {
if (!tab) return
return panes[tabKey(tab)]?.[size]
},
setPaneSize(tab: Tab | undefined, size: TabPaneSize, value: number) {
if (!tab) return
const key = tabKey(tab)
const current = panes[key]
if (current?.[size] === value) return
if (!current) {
setPanes(key, { [size]: value })
return
}
setPanes(key, size, value)
},
}
return { ...actions, store, info, ready, infoReady, recentReady, panesReady }
return { ...actions, store, info, ready, infoReady, recentReady }
},
})
+82 -2
View File
@@ -14,13 +14,63 @@
var(--v2-background-bg-layer-02);
}
[data-slot="mobile-tabs-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-tabs-backdrop-in 240ms ease-out;
}
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-tabs-backdrop-out 200ms ease-in forwards;
}
/* Keep the strip mounted for tab shortcuts and session metadata while collapsed. */
[data-slot="mobile-tabs-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
margin-block-start: 8px;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
box-shadow: var(--v2-elevation-overlay);
outline: none;
}
[data-slot="mobile-tabs-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-tabs-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-tabs-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-tabs-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-tabs-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-tabs-drawer-list"] {
@@ -29,6 +79,36 @@
flex-direction: column;
}
@keyframes mobile-tabs-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-tabs-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-tabs-drawer"][data-transitioning],
[data-slot="mobile-tabs-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-tabs-overlay"],
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
[data-slot="mobile-tabs-drawer"] [data-slot="vertical-tabs"] {
display: flex;
flex-direction: column;
+22 -12
View File
@@ -25,7 +25,7 @@ import type { ComposerState } from "@/composer/persistence"
import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import Drawer from "@corvu/drawer"
import { sessionLabel } from "@/session/title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { projectForSession } from "@/shell/layout/helpers"
@@ -415,7 +415,7 @@ export function Titlebar(props: {
<Show
when={!mobile()}
fallback={
<MobileDrawer
<Drawer
open={mobileTabs.open}
onOpenChange={(open) => setMobileTabs("open", open)}
onContentPresentChange={(present) => {
@@ -423,9 +423,11 @@ export function Titlebar(props: {
setMobileTabs("settings", false)
openSettings()
}}
side="bottom"
>
<MobileDrawerTrigger
<Drawer.Trigger
data-slot="mobile-tabs-trigger"
aria-expanded={mobileTabs.open}
class="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base focus-visible:outline-none [app-region:no-drag]"
aria-label={language.t("titlebar.tabs")}
>
@@ -465,11 +467,15 @@ export function Titlebar(props: {
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
</MobileDrawerTrigger>
<MobileDrawerContent>
<MobileDrawerLabel class="sr-only">{language.t("titlebar.tabs")}</MobileDrawerLabel>
<div data-slot="mobile-tabs-drawer" data-corvu-no-drag>
<div data-slot="mobile-tabs-drawer-list">
</Drawer.Trigger>
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-tabs-overlay" />
<Drawer.Content forceMount data-slot="mobile-tabs-drawer" dir={language.direction()}>
<Drawer.Label class="sr-only">{language.t("titlebar.tabs")}</Drawer.Label>
<div data-slot="mobile-tabs-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-tabs-drawer-list" data-corvu-no-drag>
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
@@ -487,6 +493,7 @@ export function Titlebar(props: {
</div>
<button
type="button"
data-corvu-no-drag
data-action="mobile-tabs-new-session"
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
onClick={() => {
@@ -497,7 +504,10 @@ export function Titlebar(props: {
<Icon name="plus" />
{language.t("command.session.new")}
</button>
<div class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2">
<div
class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2"
data-corvu-no-drag
>
<button
type="button"
data-action="mobile-tabs-home"
@@ -536,9 +546,9 @@ export function Titlebar(props: {
</button>
</div>
</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
}
>
<Show
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { createAnimatedPresence } from "../src/runtime/animated-presence"
import { batch, createRoot, createSignal } from "solid-js"
import { createRoot, createSignal } from "solid-js"
test("animates visibility changes without animating initial presence", () => {
createRoot((dispose) => {
@@ -47,22 +47,3 @@ test("animates the first appearance when initially hidden", () => {
dispose()
})
})
test("does not animate visibility changes across identities", () => {
createRoot((dispose) => {
const [identity, setIdentity] = createSignal("a")
const [value, setValue] = createSignal<string | undefined>("visible")
const presence = createAnimatedPresence(value, () => null, identity)
expect(presence.animate()).toBe(false)
batch(() => {
setIdentity("b")
setValue(undefined)
})
expect(presence.animate()).toBe(false)
setValue("visible")
expect(presence.animate()).toBe(true)
dispose()
})
})
-33
View File
@@ -1,7 +1,6 @@
import { Argument, Flag, GlobalFlag } from "effect/unstable/cli"
import { Schema } from "effect"
import { Spec } from "../framework/spec"
import { Updater } from "../services/updater"
export const PrintLogs = GlobalFlag.setting("print-logs")({
flag: Flag.boolean("print-logs").pipe(
@@ -57,20 +56,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
},
commands: [
Spec.make("upgrade", {
description: "Upgrade OpenCode to the latest or a specific version",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
Argument.optional,
),
method: Flag.choice("method", Updater.methods).pipe(
Flag.withAlias("m"),
Flag.withDescription("Installation method to use"),
Flag.optional,
),
},
}),
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
Spec.make("api", {
description: "Make a request to the running server",
@@ -199,24 +184,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
},
}),
Spec.make("check", {
description: "Check package plugins for updates",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Configured package target"),
Argument.optional,
),
},
}),
Spec.make("update", {
description: "Update package plugins",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Configured package target; omit to update all outdated plugins"),
Argument.optional,
),
},
}),
Spec.make("remove", {
description: "Remove a plugin from global configuration",
params: {
@@ -1,14 +0,0 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { format, inspect } from "./inventory"
export default Runtime.handler(
Commands.commands.plugin.commands.check,
Effect.fn("cli.plugin.check")(function* (input) {
const result = yield* inspect(Option.getOrUndefined(input.target))
process.stdout.write((format(result.items) || "No package plugins found") + EOL)
if (result.items.some((item) => item.error)) process.exitCode = 1
}),
)
@@ -1,101 +0,0 @@
import { EOL } from "node:os"
import { Cause, Effect, Exit } from "effect"
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Npm } from "@opencode-ai/util/npm"
import { Config } from "../../../config"
import { ServiceConfig } from "../../../services/service-config"
export interface Item {
readonly runtime: "Server" | "TUI"
readonly target: string
readonly name: string
readonly version?: string
readonly outdated: boolean
readonly error?: string
}
export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: string) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const location = { directory: process.cwd() }
const listed = yield* Effect.promise(() => client.plugin.list({ location }))
const serverTargets = new Set(
listed.data.flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : [])),
)
const server =
selected === undefined || serverTargets.has(selected)
? yield* Effect.promise(() => client.plugin.check({ location, ...(selected ? { target: selected } : {}) }))
: listed
const serverItems = server.data.flatMap((plugin): Item[] => {
if (plugin.source.type !== "package") return []
if (selected !== undefined && plugin.source.target !== selected) return []
return [
{
runtime: "Server",
target: plugin.source.target,
name: plugin.id ?? plugin.source.target,
...(plugin.source.version ? { version: displayVersion(plugin.source.version) } : {}),
outdated: plugin.source.outdated === true,
...(plugin.state.status === "failed" ? { error: plugin.state.error } : {}),
},
]
})
const config = yield* Config.Service
const info = yield* config.get()
const configured = [
...new Set(
(info.plugins ?? []).flatMap((entry) => {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) return []
return [target]
}),
),
]
const tuiTargets = yield* Effect.promise(async () => {
const installable = await Promise.all(configured.map((target) => Npm.isInstallablePackage(target)))
return configured.filter((target, index) => installable[index] && (selected === undefined || target === selected))
})
const npm = yield* Npm.Service
const tuiItems = yield* Effect.forEach(
tuiTargets,
(target) =>
Effect.gen(function* () {
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
const outdated = yield* npm.check(target).pipe(Effect.exit)
return {
runtime: "TUI" as const,
target,
name: target,
...(installed.version ? { version: displayVersion(installed.version) } : {}),
outdated: Exit.isSuccess(outdated) && outdated.value,
...(Exit.isFailure(outdated) ? { error: Cause.pretty(outdated.cause) } : {}),
}
}),
{ concurrency: "unbounded" },
)
const items = [...serverItems, ...tuiItems]
if (selected !== undefined && !items.length) return yield* Effect.fail(new Error(`Plugin is not configured: ${selected}`))
return { client, location, items }
})
export function displayVersion(version: string) {
return /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(version) ? version.slice(0, 7) : version
}
export function format(items: readonly Item[]) {
return ["Server", "TUI"]
.flatMap((runtime) => {
const rows = items.filter((item) => item.runtime === runtime)
if (!rows.length) return []
return [
runtime,
...rows.map(
(item) =>
` ${item.name}${item.version ? ` ${item.version}` : ""} (${item.error ? "check failed" : item.outdated ? "update available" : "current"})`,
),
]
})
.join(EOL)
}
@@ -8,9 +8,7 @@ import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { fileURLToPath } from "node:url"
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
import { discoverTuiPlugins, localPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -24,23 +22,15 @@ export default Runtime.handler(
const discovered = yield* Effect.promise(() =>
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
)
const npm = yield* Npm.Service
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
Effect.gen(function* () {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
return []
const local = localSource(target, path.dirname(config.path))
if (local) return [{ target: fileURLToPath(local), version: "local" }]
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
if (!installed.entrypoint) return []
return [{ target, version: installed.version }]
}),
)
const output = format(
response.data,
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
[
...(info.plugins ?? []).flatMap((entry) => {
const target = typeof entry === "string" ? entry : entry.package
return target.startsWith("-") ? [] : [{ target, source: "configured" as const }]
}),
...discovered.map((target) => ({ target, source: "discovered" as const })),
],
input.builtin,
)
if (!output) {
@@ -53,50 +43,37 @@ export default Runtime.handler(
export function format(
plugins: readonly PluginInfo[],
tui: ReadonlyArray<{ readonly target: string; readonly version?: string }>,
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
builtin = false,
) {
const server = plugins
.filter((plugin) => builtin || plugin.source.type !== "builtin")
.map((plugin) => ({
id: plugin.id ?? "-",
version:
plugin.source.type === "package"
? (plugin.source.version ?? "-")
: plugin.source.type === "local"
? "local"
: "-",
target:
plugin.source.type === "package"
? plugin.source.target
: plugin.source.type === "local"
? plugin.source.path
: plugin.source.type,
}))
const targets = tui
.filter(
(item) =>
!plugins.some((plugin) =>
plugin.source.type === "package"
? plugin.source.target === item.target
: plugin.source.type === "local" &&
(plugin.source.path === item.target ||
(plugin.features.tui &&
path.dirname(plugin.source.path) ===
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
),
)
.toSorted((a, b) => name(a).localeCompare(name(b)))
.map((plugin) => `${name(plugin)} (${plugin.state.status})`)
const advertised = plugins.flatMap((plugin) =>
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
)
const targets = [...tui, ...advertised]
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
.map((plugin) => ({ id: "-", version: plugin.version ?? "-", target: plugin.target }))
const rows = [...server, ...targets]
.toSorted((a, b) => a.id.localeCompare(b.id) || a.target.localeCompare(b.target))
.map((item) => [
item.id,
/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(item.version) ? item.version.slice(0, 7) : item.version,
item.target,
])
if (!rows.length) return ""
const table = [["ID", "VERSION", "SOURCE"], ...rows]
const widths = [0, 1].map((index) => Math.max(...table.map((row) => row[index].length)))
return table.map((row) => `${row[0].padEnd(widths[0])} ${row[1].padEnd(widths[1])} ${row[2]}`).join(EOL)
.toSorted((a, b) => a.target.localeCompare(b.target))
.map((plugin) => `${plugin.target} (${plugin.source})`)
return [
targets.length ? ["TUI", ...targets].join(EOL) : undefined,
server.length ? ["Server", ...server].join(EOL) : undefined,
]
.filter((section) => section !== undefined)
.join(EOL + EOL)
}
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
@@ -1,44 +0,0 @@
import { EOL } from "node:os"
import { Cause, Effect, Exit, Option } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { inspect } from "./inventory"
export default Runtime.handler(
Commands.commands.plugin.commands.update,
Effect.fn("cli.plugin.update")(function* (input) {
const result = yield* inspect(Option.getOrUndefined(input.target))
for (const item of result.items) {
if (!item.error) continue
process.stderr.write(`Failed to check ${item.runtime} plugin "${item.name}": ${item.error}${EOL}`)
}
const selected = result.items.filter((item) => item.outdated)
if (!selected.length) {
process.stdout.write("No plugin updates available" + EOL)
if (result.items.some((item) => item.error)) process.exitCode = 1
return
}
const npm = yield* Npm.Service
const updated = yield* Effect.forEach(
selected,
(item) =>
(item.runtime === "Server"
? Effect.promise(() => result.client.plugin.update({ location: result.location, target: item.target }))
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
).pipe(Effect.exit, Effect.map((result) => ({ item, result }))),
{ concurrency: "unbounded" },
)
for (const item of updated) {
if (Exit.isSuccess(item.result)) {
process.stdout.write(`Updated ${item.item.runtime} plugin "${item.item.name}"${EOL}`)
continue
}
process.stderr.write(
`Failed to update ${item.item.runtime} plugin "${item.item.name}": ${Cause.pretty(item.result.cause)}${EOL}`,
)
process.exitCode = 1
}
}),
)
+84 -19
View File
@@ -1,7 +1,5 @@
import { ClientError, OpenCode, type SessionStatsInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { TokenUsage } from "@opencode-ai/schema/token-usage"
import { activityCalendar } from "@opencode-ai/util/activity-calendar"
import { Effect, Option } from "effect"
import { EOL } from "node:os"
import { Commands } from "../commands"
@@ -93,7 +91,7 @@ type RenderOptions = {
const colors = terminalPalette()
export function renderStats(stats: SessionStatsInfo, options: RenderOptions) {
const totalTokens = TokenUsage.total(stats.tokens)
const totalTokens = tokenTotal(stats.tokens)
const toolTotals = stats.tools.mode === "none" ? undefined : stats.tools.totals
const terminalTools = toolTotals ? toolTotals.succeeded + toolTotals.failed : 0
const toolRate = !toolTotals || terminalTools === 0 ? undefined : (toolTotals.succeeded / terminalTools) * 100
@@ -169,23 +167,43 @@ function renderActivity(
color: boolean,
width: number,
) {
const calendar = activityCalendar({ activity, from, to, maxWeeks: width - 4 })
const months = calendar.months
.map((month, index) =>
(index === calendar.months.length - 1 || month.label.length <= month.span ? month.label : "").padEnd(month.span),
)
.join("")
.trimEnd()
const values = new Map(activity.map((day) => [day.date, day.steps]))
const rangeStart = dateOrdinal(new Date(from))
const rangeEnd = dateOrdinal(new Date(to - 1))
const end = new Date(to - 1)
end.setHours(12, 0, 0, 0)
end.setDate(end.getDate() + (7 - mondayIndex(end) - 1))
const start = new Date(from)
start.setHours(12, 0, 0, 0)
start.setDate(start.getDate() - mondayIndex(start))
const maxWeeks = Math.max(1, Math.min(53, width - 4))
const totalWeeks = Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1
const latest = new Date(end)
latest.setDate(latest.getDate() - (maxWeeks - 1) * 7)
if (start < latest) start.setTime(latest.getTime())
const active = [...values.values()].filter((value) => value > 0)
const levels = [...new Set(active)].sort((a, b) => a - b)
const weekStarts = Array.from({ length: Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 }, (_, week) => {
const date = new Date(start)
date.setDate(date.getDate() + week * 7)
return date
})
const weeks = weekStarts.map((week) =>
Array.from({ length: 7 }, (_, day) => {
const date = new Date(week)
date.setDate(date.getDate() + day)
const ordinal = dateOrdinal(date)
if (ordinal < rangeStart || ordinal > rangeEnd) return " "
return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color)
}),
)
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
return [
style(
calendar.clipped ? `activity · last ${calendar.weeks.length} weeks` : "activity",
`1;${colors.primary}`,
color,
),
` ${style(months, "2", color)}`,
style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "activity", `1;${colors.primary}`, color),
` ${style(monthLabels(weekStarts), "2", color)}`,
...weekdays.flatMap((label, day) => [
`${style(label, "2", color)} ${calendar.weeks.map((week) => (week[day].level < 0 ? " " : paintActivity(week[day].level, color))).join("")}`,
`${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`,
...(day === weekdays.length - 1 ? [] : [""]),
]),
"",
@@ -220,7 +238,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
width,
),
` ${formatNumber(TokenUsage.total(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
]),
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
]
@@ -230,7 +248,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
...models.map((item) =>
tableRow(
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
formatNumber(TokenUsage.total(item.tokens)),
formatNumber(tokenTotal(item.tokens)),
formatNumber(item.steps),
`$${item.cost.toFixed(2)}`,
),
@@ -292,6 +310,10 @@ function truncate(value: string, width: number) {
return value.length <= width ? value : value.slice(0, width - 1) + "…"
}
function tokenTotal(tokens: SessionStatsInfo["tokens"]) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
function formatNumber(value: number) {
if (value >= 1_000_000_000) return `${trimDecimal(value / 1_000_000_000)}b`
if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`
@@ -320,6 +342,13 @@ function style(value: string, code: string, color: boolean) {
return color ? `\x1b[${code}m${value}\x1b[0m` : value
}
function activityGlyph(value: number, levels: number[], color: boolean) {
if (value === 0) return paintActivity(0, color)
const index = levels.indexOf(value)
const level = Math.max(1, Math.ceil(((index + 1) / levels.length) * 4))
return paintActivity(level, color)
}
function paintActivity(level: number, color: boolean) {
const glyph = ["·", "░", "▒", "▓", "█"][level]
if (!color) return glyph
@@ -341,3 +370,39 @@ function terminalPalette() {
}
return { primary: "36", activity: ["2;36", "36", "1;36", "1;96"] }
}
function monthLabels(weeks: Date[]) {
const line: string[] = []
weeks.reduce((previous, week, index) => {
const middle = new Date(week)
middle.setDate(middle.getDate() + 3)
const month = middle.getMonth()
if (month === previous) return previous
Intl.DateTimeFormat("en-US", { month: "short" })
.format(middle)
.split("")
.forEach((character, offset) => {
line[index + offset] = character
})
return month
}, -1)
return Array.from({ length: Math.max(weeks.length, line.length) }, (_, index) => line[index] ?? " ")
.join("")
.trimEnd()
}
function mondayIndex(date: Date) {
return (date.getDay() + 6) % 7
}
function dateKey(date: Date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-")
}
function dateOrdinal(date: Date) {
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
}
@@ -1,38 +0,0 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Updater } from "../../services/updater"
import { handlePromptErrors } from "../../ui/prompt"
import { OPENCODE_VERSION } from "../../version"
export default Runtime.handler(
Commands.commands.upgrade,
Effect.fn("cli.upgrade")(function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
outro("Done")
}, handlePromptErrors),
)
-3
View File
@@ -17,7 +17,6 @@ import { CpuProfile } from "./cpu-profile"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
upgrade: () => import("./commands/handlers/upgrade"),
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
@@ -42,8 +41,6 @@ const Handlers = Runtime.handlers(Commands, {
plugin: {
list: () => import("./commands/handlers/plugin/list"),
add: () => import("./commands/handlers/plugin/add"),
check: () => import("./commands/handlers/plugin/check"),
update: () => import("./commands/handlers/plugin/update"),
remove: () => import("./commands/handlers/plugin/remove"),
},
models: () => import("./commands/handlers/models"),
-2
View File
@@ -4,7 +4,6 @@ import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
import { OPENCODE_VERSION } from "./version"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
@@ -138,7 +137,6 @@ export function createMiniHost(input: {
argv: process.argv.slice(2),
}
return {
version: OPENCODE_VERSION,
terminal: { stdin: input.terminal.stdin },
platform: process.platform,
stdout: {
+1 -1
View File
@@ -15,7 +15,7 @@ export function action(current: string, latest: string, policy: Policy): Action
return policy === "notify" ? "notify" : "upgrade"
}
export function parseReleaseVersion(input: string) {
function parseReleaseVersion(input: string) {
if (input.length > 256) return
const match = input.trim().match(versionPattern)
if (!match) return
+8 -12
View File
@@ -5,21 +5,19 @@ import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
import { action, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
type Method = "npm" | "pnpm" | "bun" | "yarn" | "curl"
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli"
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
? OPENCODE_CLI_NAME
: "@opencode-ai/cli"
export interface Interface {
readonly check: () => Effect.Effect<void>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -112,9 +110,7 @@ export const layer = Layer.effect(
return data.version
})
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
@@ -142,7 +138,7 @@ export const layer = Layer.effect(
}
return yield* run(commands[method], "5 minutes")
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
)
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
@@ -177,7 +173,7 @@ export const layer = Layer.effect(
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
)
return Service.of({ check, method, latest, upgrade })
return Service.of({ check })
}),
)
+2 -16
View File
@@ -486,14 +486,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
draft.mini = {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
}
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
})
}),
)
@@ -501,14 +494,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
},
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
})
expect(await Bun.file(path.join(directory.path, "cli.json")).text()).toContain("// Keep this comment")
})
-36
View File
@@ -1,36 +0,0 @@
import { NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Command } from "effect/unstable/cli"
import { Commands } from "../../src/commands/commands"
import upgrade from "../../src/commands/handlers/upgrade"
import { Updater } from "../../src/services/updater"
const record = (event: unknown) => console.log(`EVENT ${JSON.stringify(event)}`)
await Effect.runPromise(
Command.runWith(Commands.commands.upgrade.spec.pipe(Command.withHandler(upgrade)), { version: "test" })(
process.argv.slice(2),
).pipe(
Effect.provideService(Updater.Service, {
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
method: () =>
Effect.sync(() => {
record("method")
return Updater.methods.find((method) => method === (process.env.UPGRADE_TEST_METHOD ?? "npm"))
}),
latest: () =>
Effect.suspend(() => {
record("latest")
return process.env.UPGRADE_TEST_LATEST_ERROR
? Effect.fail(new Error("Update check failed"))
: Effect.succeed("0.0.0-beta-new")
}),
upgrade: (method, version) =>
Effect.suspend(() => {
record({ method, version })
return process.env.UPGRADE_TEST_INSTALL_ERROR ? Effect.fail(new Error("Permission denied")) : Effect.void
}),
}),
Effect.provide(NodeServices.layer),
),
)
-2
View File
@@ -9,7 +9,6 @@ import {
type InteractiveStdin,
usingInteractiveStdin,
} from "../src/mini-host"
import { OPENCODE_VERSION } from "../src/version"
import { tmpdir } from "./fixture/tmpdir"
const model = { providerID: "openai", modelID: "gpt-5" }
@@ -146,7 +145,6 @@ describe("Mini CLI host", () => {
const input = host({ stdin: stream(true), cleanup() {} }, directory.path)
expect(input.paths).toEqual({ home: directory.path })
expect(input.version).toBe(OPENCODE_VERSION)
expect(input.platform).toBe(process.platform)
expect(typeof input.files.readText).toBe("function")
const file = path.join(directory.path, "attachment.txt")
-18
View File
@@ -1,18 +0,0 @@
import { EOL } from "node:os"
import { expect, test } from "bun:test"
import { displayVersion, format, type Item } from "../src/commands/handlers/plugin/inventory"
test("formats server and TUI package update status", () => {
const items: Item[] = [
{ runtime: "Server", target: "server", name: "server.plugin", version: "1.2.3", outdated: true },
{ runtime: "TUI", target: "tui", name: "tui", version: "2.0.0", outdated: false },
]
expect(format(items)).toBe(
["Server", " server.plugin 1.2.3 (update available)", "TUI", " tui 2.0.0 (current)"].join(EOL),
)
})
test("shortens Git revisions", () => {
expect(displayVersion("dadba138b7088d61f937869bbc1ef34b1f91188d")).toBe("dadba13")
})
+22 -43
View File
@@ -2,19 +2,19 @@ import { expect, test } from "bun:test"
import { EOL } from "node:os"
import { format } from "../src/commands/handlers/plugin/list"
test("lists plugin IDs, installed versions, and sources without runtime sections", () => {
test("formats server and TUI plugins in sections without builtins", () => {
expect(
format(
[
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "acme.dual",
source: { type: "package", target: "acme-plugin@1.0.0", version: "1.0.0" },
source: { type: "package", package: "acme-plugin@1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
},
{
source: { type: "package", target: "broken-plugin" },
source: { type: "package", package: "broken-plugin" },
state: { status: "failed", error: "broken" },
features: { server: true },
},
@@ -26,22 +26,24 @@ test("lists plugin IDs, installed versions, and sources without runtime sections
},
],
[
{ target: "tui-only", version: "2.0.0" },
{ target: "/tmp/local.ts", version: "local" },
{ target: "acme-plugin@1.0.0", version: "1.0.0" },
{ target: "/tmp/local/tui.ts", version: "local" },
{ target: "tui-only", source: "configured" },
{ target: "/tmp/local.ts", source: "discovered" },
],
)
.split(EOL)
.map((line) => line.split(/\s{2,}/)),
).toEqual([
["ID", "VERSION", "SOURCE"],
["-", "local", "/tmp/local.ts"],
["-", "-", "broken-plugin"],
["-", "2.0.0", "tui-only"],
["acme.dual", "1.0.0", "acme-plugin@1.0.0"],
["local.dual", "local", "/tmp/local/index.ts"],
])
),
).toBe(
[
"TUI",
"/tmp/local (advertised)",
"/tmp/local.ts (discovered)",
"acme-plugin@1.0.0 (advertised)",
"tui-only (configured)",
"",
"Server",
"acme.dual (active)",
"broken-plugin (failed)",
"local.dual (active)",
].join(EOL),
)
})
test("includes builtins when requested", () => {
@@ -57,29 +59,6 @@ test("includes builtins when requested", () => {
],
[],
true,
)
.split(EOL)
.map((line) => line.split(/\s{2,}/)),
).toEqual([
["ID", "VERSION", "SOURCE"],
["opencode.agent", "-", "builtin"],
])
})
test("shortens Git commits and leaves absent IDs unknown", () => {
expect(
format(
[
{
source: { type: "package", target: "github:acme/plugin", version: "a".repeat(40) },
state: { status: "active" },
features: { server: true },
},
],
[],
)
.split(EOL)[1]
.split(/\s{2,}/),
).toEqual(["-", "aaaaaaa", "github:acme/plugin"])
expect(format([], [])).toBe("")
),
).toBe(["Server", "opencode.agent (active)"].join(EOL))
})
-14
View File
@@ -48,20 +48,6 @@ describe("stats rendering", () => {
expect(output).not.toContain("$12.34")
})
test("renders shared intensity levels and partial month labels", () => {
const output = renderStats(
{
...stats,
range: { from: new Date(2026, 3, 29).getTime(), to: new Date(2026, 4, 20).getTime() },
activity: [{ date: "2026-04-29", steps: 1 }],
},
options(),
)
expect(output).toContain(" May")
expect(output).not.toContain("Apr")
expect(output.split(/\r?\n/).find((line) => line.startsWith("We "))).toContain("\u2588")
})
test("renders only requested detail tables", () => {
const output = renderStats(stats, options({ tools: true, cost: true }))
expect(output).toContain("COST & TOKENS")
-233
View File
@@ -1,233 +0,0 @@
import { NodeServices } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { expect, test } from "bun:test"
import { Effect, FileSystem, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync } from "node:fs"
import path from "node:path"
import { Updater } from "../src/services/updater"
import { testEffect } from "../../core/test/lib/effect"
const it = testEffect(NodeServices.layer)
declare const OPENCODE_CLI_NAME: string | undefined
function fixture(
respond: (command: ChildProcess.StandardCommand) => Partial<AppProcess.RunResult> & {
error?: AppProcess.AppProcessError
} = () => ({}),
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const global = Global.make({
home: path.join(root, "home"),
data: path.join(root, "data"),
cache: path.join(root, "cache"),
config: path.join(root, "config"),
state: path.join(root, "state"),
tmp: path.join(root, "tmp"),
bin: path.join(root, "bin"),
log: path.join(root, "log"),
repos: path.join(root, "repos"),
})
const commands: string[][] = []
const updater = yield* Updater.Service.pipe(
Effect.provide(Updater.layer),
Effect.provideService(Global.Service, global),
Effect.provideService(
AppProcess.Service,
AppProcess.Service.of({
...spawner,
run: (command) =>
Effect.suspend(() => {
if (command._tag !== "StandardCommand") return Effect.die("Unexpected piped install command")
commands.push([command.command, ...command.args])
const result = respond(command)
if (result.error) return Effect.fail(result.error)
return Effect.succeed({
command: command.command,
exitCode: 0,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
stdoutTruncated: false,
stderrTruncated: false,
...result,
})
}),
runStream: () => Stream.die("Unexpected streaming install command"),
}),
),
)
return { updater, commands, global, fs }
})
}
const installs = [
{ method: "npm", command: ["npm", "install", "--global", "@opencode-ai/cli@2.3.4-beta.1"] },
{
method: "pnpm",
command: ["pnpm", "add", "--global", "--allow-build=@opencode-ai/cli", "@opencode-ai/cli@2.3.4-beta.1"],
},
{ method: "yarn", command: ["yarn", "global", "add", "@opencode-ai/cli@2.3.4-beta.1"] },
] as const
installs.forEach(({ method, command }) => {
it.live(`${method} installs the explicit V2 package version without a leading v`, () =>
Effect.gen(function* () {
const test = yield* fixture()
yield* test.updater.upgrade(method, "v2.3.4-beta.1")
expect(test.commands).toEqual([[...command]])
}),
)
})
;[0, 1].forEach((exitCode) => {
it.live(`bun isolates and removes its install cache after exit ${exitCode}`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => {
expect(command.command).toBe("bun")
expect(existsSync(command.args[4])).toBe(true)
return { exitCode, stderr: Buffer.from("bun install failed") }
})
const result = yield* test.updater.upgrade("bun", "v2.3.4-beta.1").pipe(Effect.flip, Effect.option)
const cache = test.commands[0]?.[5]
expect(cache).toStartWith(path.join(test.global.cache, "update-"))
expect(test.commands).toEqual([
["bun", "install", "--global", "--trust", "--cache-dir", cache, "@opencode-ai/cli@2.3.4-beta.1"],
])
expect(yield* test.fs.readDirectory(test.global.cache)).toEqual([])
expect(result._tag).toBe(exitCode === 0 ? "None" : "Some")
if (result._tag === "Some") expect(result.value.message).toBe("bun install failed")
}),
)
})
;["success", "download", "install"].forEach((failure) => {
it.live(`curl uses the V2 installer and cleans its directory: ${failure}`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => {
const installer = command.command === "curl" ? command.args[2] : command.args[0]
expect(existsSync(path.dirname(installer))).toBe(true)
return {
exitCode: command.command === (failure === "download" ? "curl" : failure === "install" ? "bash" : "") ? 1 : 0,
stderr: Buffer.from(`${failure} failed`),
}
})
const result = yield* test.updater.upgrade("curl", "v2.3.4-beta.1").pipe(Effect.flip, Effect.option)
const installer = test.commands[0]?.[3]
expect(installer).toStartWith(path.join(test.global.cache, "update-"))
expect(test.commands).toEqual([
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
...(failure === "download" ? [] : [["bash", installer, "--version", "2.3.4-beta.1", "--no-modify-path"]]),
])
expect(yield* test.fs.readDirectory(test.global.cache)).toEqual([])
expect(result._tag).toBe(failure === "success" ? "None" : "Some")
if (result._tag === "Some") expect(result.value.message).toBe(`${failure} failed`)
}),
)
})
it.live("invalid version targets never execute a command or create a cache", () =>
Effect.gen(function* () {
const test = yield* fixture()
yield* Effect.forEach(Updater.methods, (method) =>
Effect.forEach(
["", "latest", "2.3", "01.2.3", "vv2.3.4", "2.3.4; echo unsafe", "--global", "v2.3.4\n--force"],
(version) =>
Effect.gen(function* () {
const error = yield* test.updater.upgrade(method, version).pipe(Effect.flip)
expect(error.message).toBe(`Invalid version: ${version}`)
}),
),
)
expect(test.commands).toEqual([])
expect(yield* test.fs.exists(test.global.cache)).toBe(false)
}),
)
it.live("install failures expose stderr and process errors do not report success", () =>
Effect.gen(function* () {
const failed = yield* fixture(() => ({ exitCode: 1, stderr: Buffer.from(" registry denied access\n") }))
const error = yield* failed.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
expect(error.message).toBe("registry denied access")
const missing = yield* fixture(() => ({ error: new AppProcess.AppProcessError({ command: "npm" }) }))
const unavailable = yield* missing.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
expect(unavailable.message).toBe("Failed to update with npm")
expect(failed.commands).toHaveLength(1)
expect(missing.commands).toHaveLength(1)
}),
)
;(["npm", "pnpm", "bun", "yarn", undefined] as const).forEach((method) => {
it.live(`method detection identifies ${method ?? "an unknown installation"} using the V2 package`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === method ? "@opencode-ai/cli@2.3.4" : "opencode-ai@1.0.0"),
}))
expect(yield* test.updater.method()).toBe(method)
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "@opencode-ai/cli"],
["pnpm", "list", "-g", "--depth=0", "@opencode-ai/cli"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
])
}),
)
})
it.live("method detection tolerates unavailable package managers", () =>
Effect.gen(function* () {
const test = yield* fixture((command) =>
command.command === "yarn"
? { stdout: Buffer.from("@opencode-ai/cli@2.3.4") }
: { error: new AppProcess.AppProcessError({ command: command.command }) },
)
expect(yield* test.updater.method()).toBe("yarn")
expect(test.commands).toHaveLength(4)
}),
)
test("Node distribution honors the compile-time CLI name", async () => {
const child = Bun.spawn(
[
process.execPath,
"test",
import.meta.path,
"--define",
'OPENCODE_CLI_NAME="opencode2-node"',
"--test-name-pattern",
"^Node distribution resolves the published npm package$",
],
{
cwd: path.join(import.meta.dir, ".."),
stdout: "ignore",
stderr: "pipe",
// Bun 1.4 can reuse cached modules compiled with different --define values.
env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0" },
},
)
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
expect(code, stderr).toBe(0)
expect(stderr).toContain("1 pass")
})
if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node") {
it.live("Node distribution resolves the published npm package", () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
}))
expect(yield* test.updater.method()).toBe("npm")
yield* test.updater.upgrade("npm", "v2.3.4")
yield* test.updater.upgrade("pnpm", "v2.3.4")
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "opencode-node"],
["pnpm", "list", "-g", "--depth=0", "opencode-node"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
["npm", "install", "--global", "opencode-node@2.3.4"],
["pnpm", "add", "--global", "--allow-build=opencode-node", "opencode-node@2.3.4"],
])
}),
)
}
-109
View File
@@ -1,109 +0,0 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
describe("upgrade command", () => {
test("is registered in root help and documents its options", async () => {
const root = await cli(["--help"], {}, "../src/index.ts")
const help = await cli(["upgrade", "--help"], {}, "../src/index.ts")
expect(root.exitCode).toBe(0)
expect(root.stdout).toContain("upgrade")
expect(help.exitCode).toBe(0)
expect(help.stdout).toContain("[<target>]")
expect(help.stdout).toContain("--method")
expect(help.stdout).toContain("-m")
})
test("detects the installation method and resolves the latest version", async () => {
const result = await cli([])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual(["method", "latest", { method: "npm", version: "0.0.0-beta-new" }])
expect(result.stdout).toContain("Upgrade complete")
})
test("accepts an explicit version and method without detection or a version lookup", async () => {
const result = await cli(["v0.0.0-beta-target", "--method", "pnpm"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual([{ method: "pnpm", version: "v0.0.0-beta-target" }])
expect(result.stdout).toContain("0.0.0-beta-old → 0.0.0-beta-target")
})
test("accepts the short method flag and an explicit major upgrade", async () => {
const result = await cli(["2.0.0", "-m", "bun"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual([{ method: "bun", version: "2.0.0" }])
})
test("skips the already installed version", async () => {
const result = await cli(["v0.0.0-beta-old"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual(["method"])
expect(result.stdout).toContain("already installed")
})
test("requires an explicit method when detection fails", async () => {
const result = await cli([], { UPGRADE_TEST_METHOD: "unknown" })
expect(result.exitCode).toBe(1)
expect(result.events).toEqual(["method"])
expect(result.stdout).toContain("Pass --method")
})
test("rejects unsupported methods before attempting an upgrade", async () => {
const result = await cli(["--method", "brew"])
expect(result.exitCode).not.toBe(0)
expect(result.events).toEqual([])
})
test("reports version lookup failures without installing", async () => {
const result = await cli([], { UPGRADE_TEST_LATEST_ERROR: "1" })
expect(result.exitCode).toBe(1)
expect(result.events).toEqual(["method", "latest"])
expect(result.stdout).toContain("Update check failed")
})
test("reports installation failures with a nonzero exit code", async () => {
const result = await cli([], { UPGRADE_TEST_INSTALL_ERROR: "1" })
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Upgrade failed")
expect(result.stdout).toContain("Permission denied")
expect(result.stdout).not.toContain("Upgrade complete")
})
})
async function cli(args: string[], env: Record<string, string> = {}, entry = "fixture/upgrade.ts") {
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-upgrade-"))
try {
const child = Bun.spawn(
[process.execPath, "--define", 'OPENCODE_VERSION="0.0.0-beta-old"', path.join(import.meta.dir, entry), ...args],
{
cwd: path.join(import.meta.dir, ".."),
env: {
...process.env,
OPENCODE_TEST_HOME: root,
XDG_DATA_HOME: path.join(root, "data"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_STATE_HOME: path.join(root, "state"),
OPENCODE_DISABLE_AUTOUPDATE: "1",
...env,
},
stdout: "pipe",
stderr: "pipe",
},
)
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
const events = stdout
.split("\n")
.filter((line) => line.startsWith("EVENT "))
.map((line) => JSON.parse(line.slice(6)))
expect(await Bun.file(path.join(root, "state", "opencode", "service-local.json")).exists()).toBe(false)
return { stdout, stderr, exitCode, events }
} finally {
await rm(root, { recursive: true, force: true })
}
}
-16
View File
@@ -88,24 +88,8 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginCheckInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target?: string | undefined
}
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginCheckOperation<E = never> = (input?: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
export type PluginUpdateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginUpdateOutput = void
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly check: PluginCheckOperation<E>
readonly update: PluginUpdateOperation<E>
}
export type SessionListInput = {
+1 -23
View File
@@ -15,10 +15,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -326,25 +322,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
preserveEffect<PluginCheckOutput>()(
raw["plugin.check"]({ query: { location: input?.["location"] }, payload: { target: input?.["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
update: EndpointPluginUpdate(raw),
})
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
+11 -11
View File
@@ -103,12 +103,16 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
if (!service.legacy && service.state !== "ready")
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
yield* stop({
file: options.file,
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
}).pipe(Effect.ignore)
if (!service.legacy && service.state === "ready")
yield* Effect.tryPromise(() =>
PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout),
)
else {
if (!service.legacy)
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
}
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@@ -141,12 +145,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
const info = yield* read(options.file)
if (options.pty === "handoff" && info !== undefined)
yield* Effect.tryPromise(() =>
PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout),
)
else yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
})
@@ -9,10 +9,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -470,32 +466,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{
method: "POST",
path: `/api/plugin/check`,
query: { location: input?.["location"] },
body: { target: input?.["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
request<PluginUpdateOutput>(
{
method: "POST",
path: `/api/plugin/update`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 204,
declaredStatuses: [400, 503, 401],
empty: true,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+10 -34
View File
@@ -12,13 +12,13 @@ export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginSource =
| { type: "builtin" }
| { type: "package"; target: string; version?: string; outdated?: true }
| { type: "package"; package: string }
| { type: "local"; path: string }
| { type: "sdk" }
export type PluginFeatures = { server?: true; tui?: true; rpc?: true }
export type PluginState = { status: "active" } | { status: "failed"; error: string; ref?: string }
export type PluginState = { status: "active" } | { status: "failed"; error: string }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
@@ -214,7 +214,6 @@ export type GenerateTextResponse = { data: { text: string } }
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
@@ -1817,7 +1816,6 @@ export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
@@ -1995,7 +1993,6 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
canonical?: string
name?: string
env?: Array<string>
package?: string
@@ -2357,14 +2354,6 @@ export type AgentNotFoundError = {
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2426,6 +2415,14 @@ export type SkillNotFoundError = {
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
@@ -2585,27 +2582,6 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target?: { readonly target?: string | undefined }["target"]
}
export type PluginCheckOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginUpdateOutput = void
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
+9 -9
View File
@@ -84,12 +84,14 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
announce("version-mismatch", service.version)
if (!service.legacy && service.state !== "ready")
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
await stop({
file: options.file,
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
}).catch(() => undefined)
if (!service.legacy && service.state === "ready")
await PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout)
else {
if (!service.legacy)
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
await PtyHandoff.clear(options.file ?? fallback())
}
await terminate(service.info, options, timing).catch(() => undefined)
lastSpawn = 0
}
} else {
@@ -117,10 +119,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
await PtyHandoff.clear(options.file ?? fallback())
const info = await read(options.file)
if (options.pty === "handoff" && info !== undefined)
await PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
else await PtyHandoff.clear(options.file ?? fallback())
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
}
-2
View File
@@ -38,8 +38,6 @@ export type EnsureOptions = DiscoverOptions & {
export type StopOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
readonly file?: string
/** How to handle persistent terminals before stopping the service. */
readonly pty?: "clear" | "handoff"
}
/** Contents of the local service registration file. */
+15 -53
View File
@@ -147,7 +147,7 @@ function createSync() {
state.set(key, entry)
entry.promise = (wait ? wait.catch(() => undefined).then(load) : load())
.then(() => {
if (state.get(key) === entry && !entry.invalidated) state.set(key, true)
if (state.get(key) === entry) state.set(key, true)
})
.finally(() => {
if (state.get(key) === entry) state.delete(key)
@@ -493,33 +493,6 @@ export function createData(config: CreateDataInput) {
)
}
function evictSession(sessionID: string) {
if (sessionOutbox.has(sessionID)) return
sync.invalidate(`session.pending:${sessionID}`)
sync.invalidate(`session.message:${sessionID}`)
messageLoads.delete(sessionID)
// Keep unacknowledged submissions until their echo or rollback settles them.
const pending = store.session.pending[sessionID]?.filter((item) => outbox.has(item.id)) ?? []
const messages = store.session.message[sessionID]?.filter((item) => outbox.has(item.id)) ?? []
messageIndex.delete(sessionID)
if (messages.length) messageIndex.set(sessionID, new Map(messages.map((item, index) => [item.id, index])))
setStore(
"session",
produce((draft) => {
delete draft.message[sessionID]
delete draft.messageCursor[sessionID]
delete draft.messageLoading[sessionID]
delete draft.pending[sessionID]
delete draft.input[sessionID]
if (messages.length) draft.message[sessionID] = messages
if (pending.length) {
draft.pending[sessionID] = pending
draft.input[sessionID] = pending.filter((item) => item.type !== "compaction").map((item) => item.id)
}
}),
)
}
function removeSession(sessionID: string) {
activeUpdates?.set(sessionID, undefined)
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
@@ -1287,11 +1260,9 @@ export function createData(config: CreateDataInput) {
return creating.has(sessionID)
},
remember(info: SessionInfo) {
batch(() => {
setStore("session", "info", info.id, reconcile(info))
sync.complete(`session:${info.id}`)
registerSession(info.id)
})
setStore("session", "info", info.id, reconcile(info))
sync.complete(`session:${info.id}`)
registerSession(info.id)
},
setStatus(sessionID: string, status: DataSessionStatus) {
setSessionActive(sessionID, status)
@@ -1302,13 +1273,6 @@ export function createData(config: CreateDataInput) {
family(sessionID: string) {
return store.session.family[resolveRoot(sessionID)] ?? []
},
/** Clear heavy cached data for the root and all known descendants. */
evict(sessionID: string) {
const root = resolveRoot(sessionID)
batch(() => {
for (const id of new Set([root, sessionID, ...(store.session.family[root] ?? [])])) evictSession(id)
})
},
cost(sessionID: string) {
const session = store.session.info[sessionID]
if (!session) return 0
@@ -1520,19 +1484,17 @@ export function createData(config: CreateDataInput) {
: [],
])
const sessions = [info, ...children]
batch(() => {
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
)
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
})
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
)
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
})
},
invalidate(sessionID: string) {
-177
View File
@@ -1,177 +0,0 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
test("evicts a whole family while preserving metadata and attention", async () => {
const setup = fixture()
try {
for (const id of ["ses_parent", "ses_child", "ses_grandchild", "ses_other"]) {
await setup.data.session.message.sync(id)
await setup.data.session.pending.sync(id)
}
setup.data.session.setStatus("ses_child", "running")
setup.emit({
id: "evt_permission",
created: 1,
type: "permission.asked",
data: { id: "per_test", sessionID: "ses_child", action: "bash", resources: ["bun test"] },
})
setup.data.session.evict("ses_parent")
for (const id of ["ses_parent", "ses_child", "ses_grandchild"]) {
expect(setup.data.session.message.list(id)).toEqual([])
expect(setup.data.session.message.get(id, "msg_page")).toBeUndefined()
expect(setup.data.session.message.more(id)).toBe(false)
expect(setup.data.session.message.loading(id)).toBe(false)
expect(setup.data.session.pending.list(id)).toEqual([])
expect(setup.data.session.input.list(id)).toEqual([])
expect(setup.data.session.get(id)?.id).toBe(id)
}
expect(setup.data.session.family("ses_parent")).toEqual(["ses_parent", "ses_child", "ses_grandchild"])
expect(setup.data.session.status("ses_child")).toBe("running")
expect(setup.data.session.permission.list("ses_child")?.[0]?.id).toBe("per_test")
expect(setup.data.session.message.list("ses_other")).toHaveLength(2)
setup.emit({
id: "evt_agent",
created: 1,
type: "session.agent.selected",
durable: { aggregateID: "ses_parent", seq: 1, version: 1 },
data: { sessionID: "ses_parent", agent: "build" },
})
expect(setup.data.session.message.list("ses_parent")).toMatchObject([{ type: "agent-switched" }])
await setup.data.session.message.sync("ses_parent")
expect(setup.data.session.message.list("ses_parent").map((item) => item.id)).toEqual(["msg_page"])
await setup.data.session.message.sync("ses_grandchild")
await setup.data.session.pending.sync("ses_grandchild")
expect(setup.data.session.message.list("ses_grandchild")).toHaveLength(2)
expect(setup.data.session.message.list("ses_child")).toEqual([])
} finally {
setup.dispose()
}
})
test("evicting an in-flight read leaves the next sync invalidated", async () => {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
let requests = 0
const setup = fixture(async () => {
if (++requests !== 1) return
requested.resolve()
await release.promise
})
try {
const initial = setup.data.session.message.sync("ses_child")
await requested.promise
setup.data.session.evict("ses_parent")
release.resolve()
await initial
await setup.data.session.message.sync("ses_child")
expect(requests).toBe(2)
expect(setup.data.session.message.list("ses_child")).toHaveLength(1)
} finally {
release.resolve()
setup.dispose()
}
})
test.each(["echo", "reject"] as const)("eviction preserves optimistic submissions until %s", async (mode) => {
const release = Promise.withResolvers<void>()
const setup = fixture(async (url) => {
if (!url.pathname.endsWith("/prompt")) return undefined
await release.promise
return mode === "reject"
? Response.json({ message: "rejected" }, { status: 400 })
: Response.json({ id: "msg_local" })
})
try {
const request = setup.data.session.prompt({ sessionID: "ses_child", id: "msg_local", text: "local" })
const settled = Promise.allSettled([request])
setup.data.session.evict("ses_parent")
expect(setup.data.session.message.list("ses_child")).toMatchObject([{ id: "msg_local", text: "local" }])
if (mode === "echo") setup.emit(enqueued("ses_child", "msg_local"))
release.resolve()
await settled
expect(setup.data.session.message.list("ses_child")).toHaveLength(mode === "echo" ? 1 : 0)
expect(setup.data.session.pending.list("ses_child")).toHaveLength(mode === "echo" ? 1 : 0)
} finally {
release.resolve()
setup.dispose()
}
})
function info(id: string, parentID?: string): SessionInfo {
return {
id,
parentID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
location: { directory: "/project" },
}
}
function enqueued(sessionID: string, inboxID = "msg_pending"): OpenCodeEvent {
return {
id: `evt_${inboxID}`,
created: 1,
type: "session.inbox.enqueued",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, inboxID, item: { type: "user", delivery: "steer", payload: { text: "pending" } } },
}
}
function fixture(read?: (url: URL) => Promise<Response | void>) {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input) => {
const url = new URL(input instanceof Request ? input.url : String(input))
const response = await read?.(url)
if (response) return response
const sessionID = url.pathname.split("/")[3]
if (url.pathname.endsWith("/inbox"))
return Response.json({
data: [
{
id: "msg_pending",
sessionID,
type: "user",
delivery: "steer",
payload: { text: "pending" },
timeCreated: 1,
},
],
})
return Response.json({
data: [{ id: "msg_page", type: "user", text: "page", time: { created: 0 } }],
cursor: url.searchParams.has("cursor") ? {} : { next: "older" },
})
},
})
return createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
})
data.session.remember(info("ses_parent"))
data.session.remember(info("ses_child", "ses_parent"))
data.session.remember(info("ses_grandchild", "ses_child"))
data.session.remember(info("ses_other"))
return {
data,
dispose,
emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })),
}
})
}
+5 -18
View File
@@ -26,7 +26,7 @@ Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source locat
## Quick Start
```ts
import { CodeMode, Namespace, Tool } from "@opencode-ai/codemode"
import { CodeMode, Tool } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
const lookupOrder = Tool.make({
@@ -60,22 +60,9 @@ only shape the model-visible signature. Without `output`, the signature uses `Pr
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
Nested records are the shorthand for ordinary namespaces. Use `Namespace.make` when a namespace needs a description:
```ts
const runtime = CodeMode.make({
tools: {
orders: Namespace.make({
description: "Purchases, fulfillment, and shipment tracking",
tools: { lookup: lookupOrder },
}),
},
})
```
Namespace descriptions are optional and participate in search matching for every descendant tool. Names still come
from record keys, so the wrapper does not repeat `orders`. Dots in keys create nested paths; other characters use
bracket notation, such as `tools.context7["resolve-library-id"](...)`.
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
`tools.issues.list(...)`. Other characters use bracket notation, such as
`tools.context7["resolve-library-id"](...)`.
### `CodeMode.execute` and `CodeMode.make`
@@ -163,7 +150,7 @@ and `CodeMode.toolExpression(path)` supply the exact callable forms.
The synchronous `search(...)` built-in is always available. It supports exact-path lookup, namespace-scoped search,
empty-query browsing, and pagination, and returns callable paths with full signatures. Search counts toward
`maxToolCalls`. Search also matches descriptions from enclosing `Namespace` values.
`maxToolCalls`.
## Execution Limits
-1
View File
@@ -1,5 +1,4 @@
export * as CodeMode from "./codemode.js"
export * as Namespace from "./namespace.js"
export * as Tool from "./tool.js"
export * as OpenAPI from "./openapi/index.js"
export { searchSignature, toolExpression } from "./codemode.js"
@@ -1,6 +1,5 @@
import {
type AstNode,
AsyncIteratorSymbol,
CodeModeFunction,
CodeModeGenerator,
CoercionFunction,
@@ -10,7 +9,6 @@ import {
GeneratorMethodReference,
InterpreterRuntimeError,
IntrinsicReference,
IteratorSymbol,
JsonMethodReference,
PromiseCapabilityFunction,
PromiseInstanceMethodReference,
@@ -44,12 +42,13 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof SymbolNamespace ||
isCodeModeValue(value)
function* childValues(value: object): Generator {
for (const key of Reflect.ownKeys(value)) {
if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue
if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
yield Reflect.get(value, key)
function* childValues(value: object): Generator<unknown> {
if (Array.isArray(value)) {
const length = value.length
for (let index = 0; index < length; index++) yield value[index]
return
}
yield* Object.values(value)
}
export const containsRuntimeReference = (value: unknown): boolean => {
@@ -91,14 +90,9 @@ export const containsOpaqueReference = (value: unknown): boolean => {
}
// Reject cycles before mutation so later boundary walks remain safe.
export const rejectCircularInsertion = (
container: object,
value: unknown,
label: string,
node: AstNode,
seen = new Set<object>(),
): void => {
export const rejectCircularInsertion = (container: object, value: unknown, label: string, node: AstNode): void => {
const pending: Array<Iterator<unknown>> = [[value].values()]
const seen = new Set<object>()
while (pending.length > 0) {
const next = pending.at(-1)!.next()
if (next.done) {
@@ -110,7 +104,7 @@ export const rejectCircularInsertion = (
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
if (current === null || typeof current !== "object" || isRuntimeReference(current) || seen.has(current)) continue
seen.add(current)
pending.push(childValues(current))
pending.push(Array.isArray(current) ? current[Symbol.iterator]() : childValues(current))
}
}
-24
View File
@@ -1,24 +0,0 @@
import type { Tools } from "./tools.js"
/** A tool namespace with optional model-visible metadata. */
export type Namespace<R = never> = {
readonly _tag: "CodeModeNamespace"
readonly description?: string
readonly tools: Tools<R>
}
/** Options for declaring one CodeMode namespace. */
export type Options<R = never> = {
readonly description?: string
readonly tools: Tools<R>
}
export const isNamespace = <R = never>(value: Namespace<R> | Tools<R>): value is Namespace<R> =>
Object.hasOwn(value, "_tag") && value._tag === "CodeModeNamespace"
/** Declares a namespace when descriptions or other namespace metadata are needed. */
export const make = <R = never>(options: Options<R>): Namespace<R> => ({
_tag: "CodeModeNamespace",
...(options.description === undefined ? {} : { description: options.description }),
tools: options.tools,
})
+1 -1
View File
@@ -53,6 +53,7 @@ export const fromSpec = (options: Options): Result => {
if (!isRecord(pathValue)) continue
for (const [method, operationValue] of Object.entries(pathValue)) {
if (!methods.has(method) || !isRecord(operationValue)) continue
const segments = operationPath(method, path, operationValue, used, namespaces)
const operation: Operation = {
operationId: nonEmptyString(operationValue.operationId),
method: method.toUpperCase(),
@@ -98,7 +99,6 @@ export const fromSpec = (options: Options): Result => {
auth: options.auth,
headers: options.headers ?? {},
}
const segments = operationPath(method, path, operationValue, used, namespaces)
used.add(segments.join("."))
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
setTool(
+3 -1
View File
@@ -461,7 +461,9 @@ export const operationInput = (
const fields = [...parameters.value, ...requestBody.value.fields]
const conflicts = new Set(
[...Map.groupBy(fields, (field) => field.name)].filter(([, matches]) => matches.length > 1).map(([name]) => name),
[...Map.groupBy(fields, (field) => field.name)]
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
.map(([name]) => name),
)
const used = new Set<string>()
return {
+15 -20
View File
@@ -1,6 +1,12 @@
import { Effect } from "effect"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
import { containsOpaqueReference, rejectCircularInsertion } from "../interpreter/references.js"
import {
type AstNode,
AsyncIteratorSymbol,
InterpreterRuntimeError,
IteratorSymbol,
IteratorSymbols,
} from "../interpreter/model.js"
import { containsOpaqueReference } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModePromise } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
@@ -31,6 +37,10 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
}
return input as Record<string, unknown>
}
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
out[key] = item
}
switch (name) {
case "keys":
return Object.keys(requireObject())
@@ -54,29 +64,14 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
}
const out = target as Record<string, unknown>
const seen = new Set<object>()
const guardedSet = (key: PropertyKey, item: unknown): void => {
if (typeof key === "string" && isBlockedMember(key))
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
rejectCircularInsertion(out, item, "Object.assign result", node, seen)
if (!Reflect.set(out, key, item))
throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as(
"TypeError",
)
}
for (const source of args.slice(1)) {
if (source === null || source === undefined || isCodeModeValue(source)) continue
if (typeof source !== "object" || Array.isArray(source)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
for (const key of Reflect.ownKeys(source)) {
if (typeof key === "string") {
if (Object.prototype.propertyIsEnumerable.call(source, key)) guardedSet(key, Reflect.get(source, key))
continue
}
if (key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue
guardedSet(key, Reflect.get(source, key))
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
}
}
return out
+25 -34
View File
@@ -8,7 +8,6 @@ import {
inputTypeScript,
outputTypeScript,
} from "./tool-schema.js"
import { isNamespace, type Namespace } from "./namespace.js"
import { isTool, type Tool } from "./tool.js"
import type { Tools } from "./tools.js"
import {
@@ -278,7 +277,6 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
type ToolNode<R> = {
tool?: Tool<R>
namespace?: Namespace<R>
readonly children: Map<string, ToolNode<R>>
}
@@ -294,10 +292,7 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
current = child
}
if (isTool<R>(value)) current.tool = value
else if (isNamespace<R>(value)) {
current.namespace = value
insert(current, value.tools)
} else insert(current, value)
else insert(current, value)
}
}
insert(root, tools)
@@ -307,33 +302,29 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
path.flatMap((segment) => segment.split("."))
type VisibleTool<R> = {
readonly path: string
readonly tool: Tool<R>
readonly namespaces: ReadonlyArray<Namespace<R>>
}
const flattenTools = <R>(
node: ToolNode<R>,
path: ReadonlyArray<string> = [],
namespaces: ReadonlyArray<Namespace<R>> = [],
): Array<VisibleTool<R>> => {
const next = node.namespace === undefined ? namespaces : [...namespaces, node.namespace]
return [
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool, namespaces: next }]),
...Array.from(node.children).flatMap(([name, child]) => flattenTools(child, [...path, name], next)),
]
}
): Array<{ path: string; tool: Tool<R> }> => [
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]),
...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(),
]
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
path: visible.path,
description: visible.tool.description,
signature: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
path,
description: tool.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
})
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
const visibleTools = <R>(tools: Tools<R>) =>
flattenTools(toolTrie(tools)).sort((left, right) => compareText(left.path, right.path))
flattenTools(toolTrie(tools))
.sort((left, right) => compareText(left.path, right.path))
.map(({ path, tool }) => ({
path,
tool,
description: describeTool(path, tool),
}))
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
@@ -429,13 +420,12 @@ export const searchSignature = (() => {
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
})()
const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
description: describeTool(visible),
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
description,
searchText: [
visible.path,
visible.tool.description,
...visible.namespaces.flatMap((namespace) => (namespace.description === undefined ? [] : [namespace.description])),
...inputProperties(visible.tool).flatMap(({ name, description: property }) =>
path,
tool.description,
...inputProperties(tool).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property],
),
]
@@ -443,13 +433,14 @@ const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
.toLowerCase(),
})
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> => visibleTools(tools).map(toSearchEntry)
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
const visible = visibleTools(tools)
return {
catalog: visible.map(describeTool),
searchIndex: visible.map(toSearchEntry),
catalog: visible.map(({ description }) => description),
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
}
}
+12 -47
View File
@@ -63,58 +63,21 @@ const docTags = (schema: JsonSchema): Array<string> => {
} catch {}
}
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
if (schema.type === "integer") tags.push("@integer")
if (typeof schema.minimum === "number") tags.push(`@minimum ${schema.minimum}`)
if (typeof schema.maximum === "number") tags.push(`@maximum ${schema.maximum}`)
if (typeof schema.exclusiveMinimum === "number") tags.push(`@exclusiveMinimum ${schema.exclusiveMinimum}`)
if (typeof schema.exclusiveMaximum === "number") tags.push(`@exclusiveMaximum ${schema.exclusiveMaximum}`)
if (typeof schema.multipleOf === "number") tags.push(`@multipleOf ${schema.multipleOf}`)
if (typeof schema.minLength === "number") tags.push(`@minLength ${schema.minLength}`)
if (typeof schema.maxLength === "number") tags.push(`@maxLength ${schema.maxLength}`)
if (typeof schema.pattern === "string") tags.push(`@pattern ${schema.pattern}`)
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
if (schema.uniqueItems === true) tags.push("@uniqueItems true")
return tags
}
const docLines = (schema: JsonSchema, width: number): Array<string> => {
const summary = docTags(schema).join(" ")
const lines = (schema.description ?? "").split("\n").map((line) => line.replace(/\s+$/, ""))
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
)
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
const inline = lines.length === 1 ? `${lines[0]}${lines[0].endsWith(".") ? "" : "."} ${summary}` : summary
return summary && lines.length === 1 && !summary.includes("\n") && width + inline.length + 7 <= 120
? [inline]
: [...lines, ...(summary ? summary.split("\n") : [])]
}
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
const jsdoc = (schema: JsonSchema, pad: string): string => {
const content = docLines(schema, pad.length)
const types = typeof schema.type === "string" ? [schema.type] : (schema.type ?? [])
const append = (label: string, child: JsonSchema) => {
docLines(child, pad.length + label.length + 2).forEach((line, index) => {
if (index === 0) {
content.push(`${label}: ${line}`)
return
}
content.push(line ? ` ${line}` : "")
})
}
// Document only the immediate contents; recursive labels obscure which level a constraint belongs to.
if (types.includes("array") && schema.items) append("Each item", schema.items)
if ((types.includes("object") || schema.properties) && typeof schema.additionalProperties === "object") {
const label = Object.keys(schema.properties ?? {}).length > 0 ? "Each additional value" : "Each value"
append(label, schema.additionalProperties)
}
if (content.length === 0) return ""
const escaped = content.map((line) => line.replaceAll("*/", "* /"))
if (escaped.length === 1 && pad.length + escaped[0].length + 7 <= 120) return `${pad}/** ${escaped[0]} */\n`
const body = escaped.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
if (lines.length === 0) return ""
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
return `${pad}/**\n${body}\n${pad} */\n`
}
@@ -164,8 +127,8 @@ const renderSchema = (
])
}
if (schema.allOf) {
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
}
if (Array.isArray(schema.type)) {
@@ -193,7 +156,9 @@ const renderSchema = (
if (properties.length === 0 && indexType === undefined) return "{}"
const pad = " ".repeat(depth + 1)
const lines = properties.map((entry) => `${jsdoc(entry[1], pad)}${pad}${field(entry)},`)
const lines = properties.map(
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`,
)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
+7 -13
View File
@@ -1,6 +1,4 @@
import { Effect, Schema } from "effect"
import type { Namespace } from "./namespace.js"
import type { Tools } from "./tools.js"
/**
* JSON Schema subset for model-visible signatures. CodeMode does not validate values against
@@ -21,17 +19,8 @@ export type JsonSchema = {
readonly default?: unknown
readonly format?: string
readonly deprecated?: boolean
readonly minimum?: number
readonly maximum?: number
readonly exclusiveMinimum?: number
readonly exclusiveMaximum?: number
readonly multipleOf?: number
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
readonly minItems?: number
readonly maxItems?: number
readonly uniqueItems?: boolean
readonly $ref?: string
readonly $defs?: Readonly<Record<string, JsonSchema>>
readonly definitions?: Readonly<Record<string, JsonSchema>>
@@ -61,8 +50,13 @@ export type Options<I extends SchemaType, O extends SchemaType | undefined, R =
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
}
export const isTool = <R = never>(value: Tool<R> | Namespace<R> | Tools<R> | undefined): value is Tool<R> =>
value !== undefined && Object.hasOwn(value, "_tag") && value._tag === "CodeModeTool"
// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool.
export const isTool = <R = never>(value: unknown): value is Tool<R> =>
typeof value === "object" &&
value !== null &&
"_tag" in value &&
Object.hasOwn(value, "_tag") &&
value._tag === "CodeModeTool"
/**
* Declares one schema-described tool available to a CodeMode program through `tools.*`.
+1 -2
View File
@@ -1,6 +1,5 @@
import type { Namespace } from "./namespace.js"
import type { Tool } from "./tool.js"
export type Tools<R = never> = {
readonly [name: string]: Tool<R> | Namespace<R> | Tools<R>
readonly [name: string]: Tool<R> | Tools<R>
}
+4 -35
View File
@@ -25,12 +25,8 @@ const happyPathSpec = async (): Promise<Document> => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const toolAt = (tools: OpenAPI.Tools, name: string) =>
name
.split(".")
.reduce<
Tool.Tool<HttpClient.HttpClient> | OpenAPI.Tools | undefined
>((current, segment) => (current !== undefined && !Tool.isTool(current) ? current[segment] : undefined), tools)
const toolAt = (tools: unknown, name: string) =>
name.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), tools)
const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
const requests: Array<Recorded> = []
@@ -282,30 +278,6 @@ describe("OpenAPI.fromSpec", () => {
expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true)
})
test("does not reserve names for unsupported operations between duplicate operation IDs", () => {
const operation = { operationId: "group.item", responses: { 200: { description: "Success" } } }
for (const unsupported of [false, true]) {
const result = OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/first": { get: operation },
...(unsupported ? { "/unsupported": { get: { ...operation, "x-websocket": true } } } : {}),
"/last": { get: operation },
},
},
})
expect(Object.keys(result.tools)).toEqual(["group", "group_item_2"])
expect(toolAt(result.tools, "group.item")).toMatchObject({ _tag: "CodeModeTool", description: "GET /first" })
expect(toolAt(result.tools, "group_item_2")).toMatchObject({ _tag: "CodeModeTool", description: "GET /last" })
expect(result.skipped).toEqual(
unsupported ? [{ method: "GET", path: "/unsupported", reason: "WebSocket operations are not supported" }] : [],
)
}
})
test("synthesizes flat operation IDs from methods and paths", () => {
const response = { responses: { 200: { description: "Success" } } }
const tools = OpenAPI.fromSpec({
@@ -343,10 +315,7 @@ describe("OpenAPI.fromSpec", () => {
parameters: [{ name: "limit", in: "query", schema: { type: "string" } }],
get: {
operationId: "test",
parameters: [
{ name: "limit", in: "query", schema: { type: "boolean" } },
{ name: "limit", in: "query", required: true, schema: { type: "number" } },
],
parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }],
responses: { 200: { description: "Success" } },
},
},
@@ -979,7 +948,7 @@ describe("OpenAPI.fromSpec", () => {
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const health = toolAt(result.tools, "v2.health.get")
const healthInput = Tool.isTool(health) && isRecord(health.input) ? health.input : undefined
const healthInput = isRecord(health) ? health.input : undefined
expect(healthInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(healthInput) ? healthInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
+48 -417
View File
@@ -36,7 +36,7 @@ const lookupOrder = Tool.make({
})
describe("pretty signature rendering", () => {
test("described fields get compact JSDoc; undescribed and unconstrained fields get none", () => {
test("described fields get JSDoc comments; undescribed and untagged fields get none", () => {
expect(inputTypeScript(listIssues, true)).toBe(
[
"{",
@@ -44,9 +44,16 @@ describe("pretty signature rendering", () => {
" owner: string,",
" /** Cursor from the previous response's pageInfo */",
" after?: string,",
" /** Results per page. @default 30 */",
" /**",
" * Results per page",
" * @default 30",
" */",
" perPage?: number,",
" /** Filter by labels. @minItems 1 @maxItems 10 */",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" labels?: Array<string>,",
' state?: "open" | "closed",',
"}",
@@ -98,7 +105,7 @@ describe("pretty signature rendering", () => {
)
})
test("constraints and annotations share compact tagged JSDoc", () => {
test("constraints TypeScript cannot express surface as JSDoc tags", () => {
const pretty = jsonSchemaToTypeScript(
{
type: "object",
@@ -112,10 +119,19 @@ describe("pretty signature rendering", () => {
)
expect(pretty).toContain(" /** @deprecated */\n legacy?: string")
expect(pretty).toContain(" /** @format uri */\n homepage?: string")
expect(pretty).toContain(' /** @default ["a","b"] @minItems 2 @maxItems 5 */\n tags?: Array<string>')
expect(pretty).toContain(
[
" /**",
' * @default ["a","b"]',
" * @minItems 2",
" * @maxItems 5",
" */",
" tags?: Array<string>",
].join("\n"),
)
})
test("skips an unserializable default rather than emitting a broken summary", () => {
test("skips an unserializable default rather than emitting a broken tag", () => {
const pretty = jsonSchemaToTypeScript(
{ type: "object", properties: { size: { type: "number", default: 1n } } },
true,
@@ -123,248 +139,6 @@ describe("pretty signature rendering", () => {
expect(pretty).toBe(["{", " size?: number,", "}"].join("\n"))
})
test("labels immediate item and dictionary value metadata above the field", () => {
const schema = {
properties: {
recipients: {
type: "array",
description: "People to notify",
minItems: 1,
items: { type: "string", description: "Email address", format: "email" },
},
scores: { type: "object", additionalProperties: { type: "number", minimum: 0 } },
names: { type: "array", items: { type: "string", description: "Display name" } },
plain: { type: "object", additionalProperties: { type: "string" } },
},
}
expect(jsonSchemaToTypeScript(schema, true)).toBe(
[
"{",
" /**",
" * People to notify. @minItems 1",
" * Each item: Email address. @format email",
" */",
" recipients?: Array<string>,",
" /** Each value: @minimum 0 */",
" scores?: {",
" [key: string]: number,",
" },",
" /** Each item: Display name */",
" names?: Array<string>,",
" plain?: {",
" [key: string]: string,",
" },",
"}",
].join("\n"),
)
expect(jsonSchemaToTypeScript(schema)).toBe(
"{ recipients?: Array<string>; scores?: { [key: string]: number }; names?: Array<string>; plain?: { [key: string]: string } }",
)
})
test("does not apply additional property metadata to named properties", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
scores: {
type: "object",
properties: { title: { type: "string", description: "Score title" } },
additionalProperties: { type: "number", minimum: 0 },
},
},
},
true,
),
).toBe(
[
"{",
" /** Each additional value: @minimum 0 */",
" scores?: {",
" /** Score title */",
" title?: string,",
" [key: string]: number,",
" },",
"}",
].join("\n"),
)
})
test("labels array and dictionary contents in type-array unions", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
recipients: { type: ["array", "null"], items: { type: "string", format: "email" } },
scores: { type: ["object", "null"], additionalProperties: { type: "number", minimum: 0 } },
either: {
type: ["array", "object"],
items: { type: "string", minLength: 1 },
additionalProperties: { type: "number", minimum: 0 },
},
},
},
true,
),
).toBe(
[
"{",
" /** Each item: @format email */",
" recipients?: Array<string> | null,",
" /** Each value: @minimum 0 */",
" scores?: {",
" [key: string]: number,",
" } | null,",
" /**",
" * Each item: @minLength 1",
" * Each value: @minimum 0",
" */",
" either?: Array<string> | {",
" [key: string]: number,",
" },",
"}",
].join("\n"),
)
})
test("keeps multiline item metadata indented under its label and escapes terminators", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
values: {
type: "array",
minItems: 1,
items: { type: "string", description: "\nFirst */ line\n\nSecond line\n", pattern: "^\\d+\n*/$" },
},
},
},
true,
),
).toBe(
[
"{",
" /**",
" * @minItems 1",
" * Each item: First * / line",
" *",
" * Second line",
" * @pattern ^\\d+",
" * * /$",
" */",
" values?: Array<string>,",
"}",
].join("\n"),
)
})
test("does not flatten nested containers, reference targets, or branches into item metadata", () => {
expect(
jsonSchemaToTypeScript(
{
$defs: { Email: { type: "string", format: "email" } },
properties: {
matrix: { type: "array", items: { type: "array", minItems: 2, items: { type: "integer", minimum: 0 } } },
refs: { type: "array", items: { $ref: "#/$defs/Email", description: "Recipient" } },
choices: {
type: "array",
items: {
anyOf: [
{ type: "string", minLength: 1 },
{ type: "number", minimum: 0 },
],
},
},
},
},
true,
),
).toBe(
[
"{",
" /** Each item: @minItems 2 */",
" matrix?: Array<Array<number>>,",
" /** Each item: Recipient */",
" refs?: Array<string>,",
" choices?: Array<string | number>,",
"}",
].join("\n"),
)
})
test.each([
[{ type: "number", minimum: 0 }, "@minimum 0", "number"],
[{ type: "number", maximum: 0 }, "@maximum 0", "number"],
[{ type: "number", exclusiveMinimum: 0 }, "@exclusiveMinimum 0", "number"],
[{ type: "number", exclusiveMaximum: 0 }, "@exclusiveMaximum 0", "number"],
[{ type: "number", multipleOf: 0.25 }, "@multipleOf 0.25", "number"],
[{ type: "string", minLength: 0 }, "@minLength 0", "string"],
[{ type: "string", maxLength: 0 }, "@maxLength 0", "string"],
[{ type: "string", pattern: "^[a-z]+$" }, "@pattern ^[a-z]+$", "string"],
[{ type: "array", minItems: 0 }, "@minItems 0", "Array<unknown>"],
[{ type: "array", maxItems: 0 }, "@maxItems 0", "Array<unknown>"],
[{ type: "array", uniqueItems: true }, "@uniqueItems true", "Array<unknown>"],
[{ type: "string", minLength: 0, maxLength: 0 }, "@minLength 0 @maxLength 0", "string"],
[{ type: "array", minItems: 0, maxItems: 0 }, "@minItems 0 @maxItems 0", "Array<unknown>"],
[
{ type: "array", minItems: 1, maxItems: 10, uniqueItems: true },
"@minItems 1 @maxItems 10 @uniqueItems true",
"Array<unknown>",
],
] as const)("renders constraint %j without changing the compact type", (value, summary, type) => {
const schema = { type: "object", properties: { value } }
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", ` /** ${summary} */`, ` value?: ${type},`, "}"].join("\n"),
)
expect(jsonSchemaToTypeScript(schema)).toBe(`{ value?: ${type} }`)
})
test("documents integer numbers without adding redundant types or requiring uniqueness when false", () => {
expect(
jsonSchemaToTypeScript(
{
type: "object",
properties: {
count: { type: "integer" },
amount: { type: "number" },
name: { type: "string" },
enabled: { type: "boolean" },
values: { type: "array", uniqueItems: false },
choice: { type: ["integer", "string"] },
},
},
true,
),
).toBe(
[
"{",
" /** @integer */",
" count?: number,",
" amount?: number,",
" name?: string,",
" enabled?: boolean,",
" values?: Array<unknown>,",
" choice?: number | string,",
"}",
].join("\n"),
)
})
test.each([false, null, ""])("preserves default %j alongside constraints", (value) => {
expect(jsonSchemaToTypeScript({ properties: { value: { default: value, minLength: 0 } } }, true)).toContain(
` /** @default ${JSON.stringify(value)} @minLength 0 */\n`,
)
})
test("escapes comment terminators in summary values", () => {
expect(
jsonSchemaToTypeScript(
{ properties: { value: { type: "string", default: "*/", format: "*/", pattern: "^a*/b$" } } },
true,
),
).toBe(["{", ' /** @default "* /" @format * / @pattern ^a* /b$ */', " value?: string,", "}"].join("\n"))
})
test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
const pretty = jsonSchemaToTypeScript(
{ type: "object", properties: { note: { type: "string", description: "Ends */ early" } } },
@@ -387,88 +161,6 @@ describe("pretty signature rendering", () => {
)
})
test("preserves inclusive and exclusive numeric bounds together", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
value: {
type: "integer",
minimum: -10,
maximum: 10,
exclusiveMinimum: -5,
exclusiveMaximum: 5,
multipleOf: 2,
},
},
},
true,
),
).toContain(
" /** @integer @minimum -10 @maximum 10 @exclusiveMinimum -5 @exclusiveMaximum 5 @multipleOf 2 */\n value?: number,",
)
})
test.each([
["Maximum attempts", "Maximum attempts."],
["Maximum attempts.", "Maximum attempts."],
["Maximum attempts!", "Maximum attempts!."],
])("combines a short description (%s) with its summary", (description, expected) => {
expect(
jsonSchemaToTypeScript(
{ properties: { attempts: { description, type: "integer", minimum: 1, default: 3 } } },
true,
),
).toContain(` /** ${expected} @default 3 @integer @minimum 1 */\n`)
})
test("keeps multiline descriptions intact and appends a compact summary", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
attempts: {
description: "\nMaximum attempts\n\nIncludes the initial request.\n",
type: "integer",
minimum: 1,
},
},
},
true,
),
).toBe(
[
"{",
" /**",
" * Maximum attempts",
" *",
" * Includes the initial request.",
" * @integer @minimum 1",
" */",
" attempts?: number,",
"}",
].join("\n"),
)
})
test("uses a block for long descriptions without truncating or rewriting them", () => {
const description = "A detailed description. ".repeat(8).trim()
expect(jsonSchemaToTypeScript({ properties: { name: { type: "string", description, minLength: 1 } } }, true)).toBe(
["{", " /**", ` * ${description}`, " * @minLength 1", " */", " name?: string,", "}"].join("\n"),
)
})
test("preserves pattern backslashes and prefixes every line of multiline summary values", () => {
expect(
jsonSchemaToTypeScript(
{ properties: { value: { type: "string", pattern: "^\\d+\n*/$", default: "a\nb" } } },
true,
),
).toBe(
["{", " /**", ' * @default "a\\nb" @pattern ^\\d+', " * * /$", " */", " value?: string,", "}"].join("\n"),
)
})
test("stays total on cyclic $refs and pathological nesting in both modes", () => {
const cyclic = {
$ref: "#/$defs/Node",
@@ -652,101 +344,33 @@ describe("union schemas render every alternative", () => {
expect(outputTypeScript(tool)).toBe("number | boolean")
})
test("allOf keeps siblings and parenthesized union members in order", () => {
test("allOf renders intersections with parenthesized union members", () => {
const schema = {
properties: { common: { type: "boolean" } },
allOf: [{ type: "object", properties: { id: { type: "string" } } }, { type: ["string", "null"] }],
} as const
expect(jsonSchemaToTypeScript(schema)).toBe("{ common?: boolean } & { id?: string } & (string | null)")
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", " common?: boolean,", " } & {", " id?: string,", " } & (string | null)"].join("\n"),
)
expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)")
})
test.each([false, true])("allOf does not discard an unresolved constraint (pretty=%s)", (pretty) => {
for (const $ref of ["#/$defs/Missing", "#/definitions/Missing", "https://example.com/external.json"]) {
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref }] }, pretty)).toBe("unknown")
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { allOf: [{ $ref }] }] }, pretty)).toBe("unknown")
expect(
jsonSchemaToTypeScript({ allOf: [{ properties: { nested: { $ref } } }, { type: "string" }] }, pretty),
).toBe("unknown")
}
test("allOf does not discard an unresolved constraint", () => {
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe(
"unknown",
)
expect(
jsonSchemaToTypeScript(
{
type: "string",
allOf: [{ $ref: "#/$defs/Constraint" }],
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
},
pretty,
),
jsonSchemaToTypeScript({
allOf: [{ type: "string" }, { allOf: [{ $ref: "https://example.com/external.json" }] }],
}),
).toBe("unknown")
expect(
jsonSchemaToTypeScript({
type: "string",
allOf: [{ $ref: "#/$defs/Constraint" }],
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
}),
).toBe("string")
})
})
describe("JSDoc signatures in catalogs and search results", () => {
test.each([
{
source: "JSON Schema",
schema: {
type: "object",
properties: {
count: { type: "integer", minimum: 0, maximum: 10 },
name: { type: "string", minLength: 1, maxLength: 20, pattern: "^[a-z]+$" },
labels: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1, maxItems: 5 },
scores: { type: "object", additionalProperties: { type: "integer", minimum: 0 } },
},
required: ["count", "name", "labels", "scores"],
},
},
{
source: "Effect",
schema: Schema.Struct({
count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(10)),
name: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(20), Schema.isPattern(/^[a-z]+$/)),
labels: Schema.Array(Schema.String.check(Schema.isMinLength(1))).check(
Schema.isMinLength(1),
Schema.isMaxLength(5),
),
scores: Schema.Record(Schema.String, Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
}),
},
])("$source constraints survive input/output catalog and search signatures", async ({ schema }) => {
const runtime = CodeMode.make({
tools: {
constrained: Tool.make({
description: "Constrained tool",
input: schema,
output: schema,
execute: () => Effect.succeed({ count: 1, name: "test", labels: ["test"], scores: { test: 1 } }),
}),
},
})
const type = [
"{",
" /** @integer @minimum 0 @maximum 10 */",
" count: number,",
" /** @minLength 1 @maxLength 20 @pattern ^[a-z]+$ */",
" name: string,",
" /**",
" * @minItems 1 @maxItems 5",
" * Each item: @minLength 1",
" */",
" labels: Array<string>,",
" /** Each value: @integer @minimum 0 */",
" scores: {",
" [key: string]: number,",
" },",
"}",
].join("\n")
const signature = `tools.constrained(input: ${type}): Promise<${type}>`
expect(runtime.catalog()[0]?.signature).toBe(signature)
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.constrained" })'))
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
expect(result.value).toMatchObject({ items: [{ signature }] })
})
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
const search = async (query: string) => {
@@ -756,7 +380,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
return result.value as { items: Array<{ path: string; signature: string }>; remaining: number }
}
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and summaries", async () => {
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => {
const { items } = await search("list issues repository")
const item = items.find(({ path }) => path === "tools.github.list_issues")!
expect(item.signature).toBe(
@@ -766,9 +390,16 @@ describe("JSDoc signatures in catalogs and search results", () => {
" owner: string,",
" /** Cursor from the previous response's pageInfo */",
" after?: string,",
" /** Results per page. @default 30 */",
" /**",
" * Results per page",
" * @default 30",
" */",
" perPage?: number,",
" /** Filter by labels. @minItems 1 @maxItems 10 */",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" labels?: Array<string>,",
' state?: "open" | "closed",',
"}): Promise<unknown>",
-170
View File
@@ -17,8 +17,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import { AsyncIteratorSymbol, IteratorSymbol } from "../src/interpreter/model.js"
import { invokeObjectMethod } from "../src/stdlib/object.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
@@ -826,174 +824,6 @@ describe("stdlib integration", () => {
expect(await value(`try { Object.assign(null, { a: 1 }); return false } catch { return true }`)).toBe(true)
})
test("Object.assign ignores non-enumerable supported symbols without reading them", () => {
const target = {}
const reads: Array<boolean> = []
const source = Object.defineProperty({}, IteratorSymbol, {
get() {
reads.push(true)
return target
},
})
expect(invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toBe(target)
expect(reads).toEqual([])
expect(Object.hasOwn(target, IteratorSymbol)).toBe(false)
})
test("Object.assign ignores nested non-enumerable supported symbols during cycle checks", () => {
const target = {}
const reads: Array<boolean> = []
const nested = Object.defineProperty({}, IteratorSymbol, {
get() {
reads.push(true)
return target
},
})
expect(invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toBe(target)
expect(reads).toEqual([])
expect(target).toEqual({ nested })
})
test("Object.assign rejects cycles through supported symbols on nested arrays", () => {
const target = {}
const nested = Object.defineProperty([], IteratorSymbol, { enumerable: true, value: target })
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
"Object.assign result contains a circular value.",
)
expect(Object.hasOwn(target, "nested")).toBe(false)
})
test("Object.assign cycle checks traverse sparse keys lazily", () => {
const target = {}
const reads: Array<boolean> = []
const nested = Object.defineProperties([], {
4294967294: { enumerable: true, value: target },
later: {
enumerable: true,
get() {
reads.push(true)
return null
},
},
})
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
"Object.assign result contains a circular value.",
)
expect(reads).toEqual([])
})
test("Object.assign stops after a supported symbol write fails", () => {
const previous = () => ({ done: true })
const target = Object.defineProperty({}, IteratorSymbol, { value: previous })
const reads: Array<boolean> = []
const source = Object.defineProperties(
{},
{
[IteratorSymbol]: { enumerable: true, value: () => ({ done: false }) },
[AsyncIteratorSymbol]: {
enumerable: true,
get() {
reads.push(true)
return () => ({ done: true })
},
},
},
)
expect(() => invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toThrow(
"Object.assign could not assign property",
)
expect(Reflect.get(target, IteratorSymbol)).toBe(previous)
expect(reads).toEqual([])
})
test("Object.assign rejects direct and nested cycles", async () => {
expect(
await value(`
const target = { kept: true }
try { Object.assign(target, { self: target }) } catch { return target }
return null
`),
).toEqual({ kept: true })
expect(
await value(`
const target = { kept: true }
const nested = { target }
try { Object.assign(target, { nested }) } catch { return target }
return null
`),
).toEqual({ kept: true })
expect(
await value(`
const target = {}
const source = {}
source[Symbol.iterator] = target
try { Object.assign(target, source) } catch { return Object.hasOwn(target, Symbol.iterator) }
return true
`),
).toBe(false)
expect(
await value(`
const target = {}
const nested = {}
nested[Symbol.iterator] = target
try { Object.assign(target, { nested }) } catch { return Object.hasOwn(target, "nested") }
return true
`),
).toBe(false)
})
test("Object.assign preserves mutations before a circular field", async () => {
expect(
await value(`
const target = {}
try { Object.assign(target, { before: 1, cycle: { target }, after: 2 }) } catch { return target }
return null
`),
).toEqual({ before: 1 })
expect(
await value(`
const target = {}
const marker = {}
const source = {}
source[Symbol.iterator] = marker
source[Symbol.asyncIterator] = target
try { Object.assign(target, source) } catch {
return [target[Symbol.iterator] === marker, Object.hasOwn(target, Symbol.asyncIterator)]
}
return null
`),
).toEqual([true, false])
})
test("Object.assign preserves target identity and acyclic shared aliases", async () => {
expect(
await value(`
const shared = { count: 1 }
const target = {}
const result = Object.assign(target, { left: shared, right: shared })
result.left.count = 2
return [result === target, result.left === shared, result.left === result.right, shared.count]
`),
).toEqual([true, true, true, 2])
})
test("Object.assign traverses shared aliases once", () => {
const reads: Array<boolean> = []
const shared = Object.defineProperty({}, "value", {
enumerable: true,
get() {
reads.push(true)
return 1
},
})
const target = {}
expect(invokeObjectMethod("assign", [target, { left: shared, right: shared }], { type: "CallExpression" })).toBe(
target,
)
expect(target).toEqual({ left: shared, right: shared })
expect(reads).toEqual([true])
})
test("assignment resolves and reads its left side before evaluating the right side", async () => {
expect(await value(`let x = 1; x += (x = 5); return x`)).toBe(6)
expect(await value(`let i = 0; const values = [9]; values[i++] = i; return [values, i]`)).toEqual([[1], 1])
+1 -43
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Namespace, Tool } from "../src/index.js"
import { CodeMode, Tool } from "../src/index.js"
const echo = (description: string, result: string) =>
Tool.make({
@@ -177,48 +177,6 @@ describe("blocked member names on tool paths", () => {
})
})
describe("namespace metadata", () => {
const tools = {
api: Namespace.make({
description: "Manage the workspace",
tools: {
users: Namespace.make({
description: "Directory and account administration",
tools: { list: echo("List users", "users") },
}),
status: echo("Read service status", "ok"),
},
}),
plain: { read: echo("Read plain data", "plain") },
}
const runtime = CodeMode.make({ tools })
test("the wrapper does not add a segment to callable paths", async () => {
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["api.status", "api.users.list", "plain.read"])
expect(await value(runtime, `return await tools.api.users.list({})`)).toBe("users")
})
test("search matches descriptions from every enclosing namespace", async () => {
const workspace = await value(runtime, `return search({ query: "workspace" })`)
expect((workspace as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.api.status",
"tools.api.users.list",
])
const directory = await value(runtime, `return search({ query: "account administration" })`)
expect((directory as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.api.users.list",
])
})
test("a namespace description is optional", async () => {
const optional = CodeMode.make({
tools: { api: Namespace.make({ tools: { read: echo("Read data", "read") } }) },
})
expect(await value(optional, `return await tools.api.read({})`)).toBe("read")
})
})
describe("empty segments", () => {
test("tool names with empty segments are rejected at make", () => {
for (const name of ["", "a..b", "trail.", ".lead"]) {
+5 -8
View File
@@ -119,7 +119,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
function prepareOptions(model: Info, pkg: string) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.canonical ?? model.providerID,
name: model.providerID,
...(model.settings ?? {}),
headers: model.headers,
body: projected.body,
@@ -249,7 +249,6 @@ export const locationLayer = Layer.effect(
language: Effect.fn("AISDK.language")(function* (model) {
const key = cacheKey({
providerID: model.providerID,
canonical: model.canonical,
id: model.id,
modelID: model.modelID,
package: model.package,
@@ -270,7 +269,6 @@ export const locationLayer = Layer.effect(
const options = prepareOptions(model, packageName)
const sdkKey = cacheKey({
providerID: model.providerID,
canonical: model.canonical,
package: packageName,
settings: model.settings,
headers: model.headers,
@@ -303,11 +301,10 @@ export const locationLayer = Layer.effect(
function modelFromLanguage(info: Info, language: LanguageModelV3) {
const packageName = Provider.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const providerID = info.canonical ?? info.providerID
const optionKey = providerOptionKey(packageName, providerID)
const optionKey = providerOptionKey(packageName, info.providerID)
const route: AnyRoute = {
id: `ai-sdk:${packageName}`,
provider: ProviderID.make(providerID),
provider: ProviderID.make(info.providerID),
providerMetadataKey: optionKey,
protocol: "ai-sdk",
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
@@ -334,13 +331,13 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
},
with: () => route,
model: (input) =>
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : providerID, route }),
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
}
return LanguageModel.make({
id: info.modelID ?? info.id,
provider: providerID,
provider: info.providerID,
route,
compatibility: info.compatibility,
})
-1
View File
@@ -74,7 +74,6 @@ const layer = Layer.effect(
const projectModel = (model: Model.Info, provider: Provider.Info) => {
return {
...model,
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider.package,
settings: Provider.mergeOverlay(provider.settings, model.settings),
headers: Provider.mergeHeaders(provider.headers, model.headers),
+11 -85
View File
@@ -2,34 +2,21 @@ export * as CodeModeCatalog from "./catalog.js"
import { Schema } from "effect"
export const Tool = Schema.Struct({
type: Schema.Literal("tool"),
name: Schema.String,
export const Entry = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
pinned: Schema.optionalKey(Schema.Boolean),
})
export type Tool = typeof Tool.Type
export type Namespace = {
readonly type: "namespace"
readonly name: string
readonly description?: string
readonly tools: ReadonlyArray<Tool | Namespace>
}
export type Inventory = {
readonly tools: ReadonlyArray<Tool | Namespace>
}
export type Entry = typeof Entry.Type
const Listing = Schema.Struct({
path: Schema.String,
line: Schema.String,
})
const NamespaceSummary = Schema.Struct({
const Namespace = Schema.Struct({
name: Schema.String,
description: Schema.optionalKey(Schema.String),
count: Schema.Number,
entries: Schema.Array(Listing),
})
@@ -37,31 +24,24 @@ const NamespaceSummary = Schema.Struct({
export const Summary = Schema.Struct({
total: Schema.Number,
shown: Schema.Number,
namespaces: Schema.Array(NamespaceSummary),
namespaces: Schema.Array(Namespace),
})
export type Summary = typeof Summary.Type
export type Options = {
readonly budget?: number
}
const DESCRIPTION_LIMIT = 120
const CHARACTERS_PER_TOKEN = 4
const INLINE_BUDGET = 2_000
// Keep every namespace visible, then select full listings one per namespace per round,
// Keep every namespace searchable, then select full listings one per namespace per round,
// considering shorter listings first until the inline budget is exhausted.
export function summarize(inventory: Inventory, options: Options = {}): Summary {
const budget = options.budget ?? INLINE_BUDGET
const flattened = flatten(inventory.tools)
const namespaces = [...Map.groupBy(flattened.tools, (tool) => tool.path.split(".", 1)[0] ?? tool.path)]
export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary {
const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)]
.sort(([left], [right]) => {
if (left < right) return -1
if (left > right) return 1
return 0
})
.map(([name, namespaceEntries]) => {
const description = flattened.namespaces.get(name)?.description
const listings = namespaceEntries
.map((entry) => {
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
@@ -84,7 +64,6 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary
)
return {
name,
...(description === undefined ? {} : { description }),
listings,
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
selectedListings: pinned,
@@ -93,25 +72,11 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary
})
const active = new Set(namespaces)
// TODO: Bound namespace discovery once large namespace inventories and descriptions can no longer stay inline.
let remaining =
budget -
namespaces.reduce(
(total, namespace) =>
total +
cost(
namespaceLine({
name: namespace.name,
...(namespace.description === undefined ? {} : { description: namespace.description }),
count: namespace.listings.length,
entries: [],
}),
),
0,
) -
namespaces
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
.reduce((total, listing) => total + cost(listing.line), 0)
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
while (active.size > 0) {
for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectionIndex]
@@ -128,54 +93,19 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary
const namespaceSummaries = namespaces.map((namespace) => ({
name: namespace.name,
...(namespace.description === undefined ? {} : { description: namespace.description }),
count: namespace.listings.length,
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
}))
return {
total: flattened.tools.length,
total: entries.length,
shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0),
namespaces: namespaceSummaries,
}
}
function flatten(entries: ReadonlyArray<Tool | Namespace>, path: ReadonlyArray<string> = []) {
const tools: Array<Omit<Tool, "name"> & { readonly path: string }> = []
const namespaces = new Map<string, Namespace>()
for (const entry of entries) {
if (entry.type === "tool") {
tools.push({
type: "tool",
path: [...path, entry.name].join("."),
description: entry.description,
signature: entry.signature,
...(entry.pinned === undefined ? {} : { pinned: entry.pinned }),
})
continue
}
const next = [...path, entry.name]
namespaces.set(next.join("."), entry)
const nested = flatten(entry.tools, next)
tools.push(...nested.tools)
for (const [name, namespace] of nested.namespaces) namespaces.set(name, namespace)
}
return { tools, namespaces }
}
export function namespaceLine(namespace: typeof NamespaceSummary.Type) {
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
const label =
namespace.entries.length === namespace.count
? count
: namespace.entries.length === 0
? `${count}, none shown`
: `${count}, ${namespace.entries.length} shown`
return `- ${namespace.name} (${label})${namespace.description === undefined ? "" : ` // ${namespace.description}`}`
}
function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
return listings
.map((listing) => ({ listing, cost: cost(listing.line) }))
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) }))
.toSorted((left, right) => {
if (left.cost !== right.cost) return left.cost - right.cost
if (left.listing.path < right.listing.path) return -1
@@ -183,7 +113,3 @@ function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
return 0
})
}
function cost(text: string) {
return Math.round(text.length / CHARACTERS_PER_TOKEN)
}
+10 -12
View File
@@ -23,7 +23,14 @@ export function render(catalog: CodeModeCatalog.Summary) {
return "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool."
const tools = catalog.namespaces.flatMap((namespace) => {
return [CodeModeCatalog.namespaceLine(namespace), ...namespace.entries.map((entry) => entry.line)]
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
const label =
namespace.entries.length === namespace.count
? count
: namespace.entries.length === 0
? `${count}, none shown`
: `${count}, ${namespace.entries.length} shown`
return [`- ${namespace.name} (${label})`, ...namespace.entries.map((entry) => entry.line)]
})
return `${prompt(catalog.shown < catalog.total)}
@@ -40,15 +47,6 @@ ${render(current)}`
const currentComplete = current.shown === current.total
if (previousComplete !== currentComplete) return replacement
const descriptions = Instructions.diffByKey(
previous.namespaces.filter((namespace) => namespace.description !== undefined),
current.namespaces.filter((namespace) => namespace.description !== undefined),
(namespace) => namespace.name,
(before, after) => before.description !== after.description,
)
if (descriptions.added.length > 0 || descriptions.removed.length > 0 || descriptions.changed.length > 0)
return replacement
const diff = Instructions.diffByKey(
previous.namespaces.flatMap((namespace) => namespace.entries),
current.namespaces.flatMap((namespace) => namespace.entries),
@@ -128,8 +126,8 @@ ${render(current)}`
const key = Instructions.Key.make("core/codemode")
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
export const make = (inventory?: CodeModeCatalog.Inventory): Instructions.List => {
const catalog = inventory === undefined ? Instructions.removed : CodeModeCatalog.summarize(inventory)
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.List => {
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
return Instructions.make({
key,
codec,
+14 -128
View File
@@ -1,18 +1,9 @@
export * as CodeModeTool from "./tool.js"
import { CodeMode, Namespace, Tool, toolError } from "@opencode-ai/codemode"
import type {
Content,
Context,
Error,
Info,
Metadata,
Namespace as ToolNamespace,
Result,
} from "@opencode-ai/schema/tool"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
import { CodeModeCatalog } from "./catalog.js"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -40,23 +31,6 @@ type CollectedFiles = {
readonly files: Array<typeof ExecuteFile.Type>
}
type Node<T> = {
tool?: T
namespace?: ToolNamespace
readonly children: Map<string, Node<T>>
}
type ToolNode = Node<Tool.Tool<never>>
type Tools = {
[name: string]: Tool.Tool<never> | Namespace.Namespace<never> | Tools
}
export type Inventory = {
readonly tools: ReadonlyMap<string, Info>
readonly namespaces?: ReadonlyMap<string, ToolNamespace>
}
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
@@ -68,7 +42,7 @@ const description = [
].join("\n")
export const create = (
inventory: Inventory,
registrations: ReadonlyMap<string, Info>,
executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error>,
) => {
return {
@@ -87,7 +61,7 @@ export const create = (
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
)
const result = yield* runtime(
inventory,
registrations,
(name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
@@ -158,124 +132,36 @@ export const create = (
} satisfies Info
}
export const catalog = (inventory: Inventory) => {
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
const pinned = new Set(
Array.from(inventory.tools.values())
Array.from(registrations.values())
.filter((registration) => registration.options?.pinned === true)
.map(qualifiedName),
)
const root: CatalogNode = { children: new Map() }
for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace
for (const tool of runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable"))).catalog())
getNode(root, tool.path).tool = {
type: "tool",
name: tool.path.split(".").at(-1) ?? tool.path,
description: tool.description,
signature: tool.signature,
pinned: pinned.has(tool.path),
}
return {
tools: renderCatalog(root),
} satisfies CodeModeCatalog.Inventory
}
type CatalogNode = Node<CodeModeCatalog.Tool>
function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool | CodeModeCatalog.Namespace> {
return Array.from(root.children)
.toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.flatMap(([name, node]) => {
const tools = renderCatalog(node)
const namespace =
node.namespace === undefined && tools.length === 0
? undefined
: {
type: "namespace" as const,
name,
...(node.namespace?.description === undefined ? {} : { description: node.namespace.description }),
tools,
}
if (node.tool === undefined) return namespace === undefined ? [] : [namespace]
if (namespace === undefined) return [node.tool]
return [node.tool, namespace]
})
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
}
function runtime(
inventory: Inventory,
registrations: ReadonlyMap<string, Info>,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
// A path may carry namespace metadata, a callable tool, child tools, or all three.
const root: ToolNode = { children: new Map() }
for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace
for (const [name, registration] of inventory.tools) {
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(registration)
getNode(root, qualifiedName(registration)).tool = Tool.make({
const path = qualifiedName(registration)
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema ?? Schema.NullOr(Schema.String),
execute: (input) => executeTool(name, registration, input),
})
}
const tools = renderTools(root)
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function getNode<T>(root: Node<T>, path: string) {
return path.split(".").reduce((parent, name) => {
const child: Node<T> = parent.children.get(name) ?? { children: new Map() }
parent.children.set(name, child)
return child
}, root)
}
function renderTools(root: ToolNode) {
const callables = new Map<string, Tool.Tool<never>>()
const tools = renderChildren(root, [], callables)
for (const [path, tool] of callables) tools[path] = tool
return tools
}
function renderChildren(node: ToolNode, path: ReadonlyArray<string>, callables: Map<string, Tool.Tool<never>>): Tools {
return Object.fromEntries(
Array.from(node.children).flatMap(([name, child]) => {
const next = [...path, name]
// A record cannot hold both a top-level tool and namespace under the same key.
if (path.length === 0 && child.tool !== undefined && (child.namespace !== undefined || child.children.size > 0)) {
const tools: Tools = {}
flattenTools(child, next, tools)
return Object.entries(tools)
}
return [[name, renderEntry(child, next, callables)]]
}),
)
}
function renderEntry(
node: ToolNode,
path: ReadonlyArray<string>,
callables: Map<string, Tool.Tool<never>>,
): Tools[string] {
const tools = renderChildren(node, path, callables)
// CodeMode merges this dotted tool path with the nested namespace entry.
if (node.tool !== undefined && (node.namespace !== undefined || node.children.size > 0))
callables.set(path.join("."), node.tool)
if (node.namespace !== undefined)
return Namespace.make({
description: node.namespace.description,
tools,
})
if (node.tool === undefined) return tools
if (node.children.size === 0) return node.tool
return tools
}
function flattenTools(node: ToolNode, path: ReadonlyArray<string>, tools: Tools) {
if (node.tool !== undefined) tools[path.join(".")] = node.tool
for (const [name, child] of node.children) flattenTools(child, [...path, name], tools)
}
function qualifiedName(registration: Info) {
const normalized = normalizedName(registration)
if (registration.options?.namespace === undefined) return normalized

Some files were not shown because too many files have changed in this diff Show More