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
265 changed files with 4029 additions and 12876 deletions
-2
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:",
},
},
+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()
})
})
-15
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",
@@ -54,7 +54,7 @@ export function format(
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.target, source: "advertised" as const }]
? [{ target: plugin.source.package, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
@@ -73,7 +73,7 @@ export function format(
function name(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
}
@@ -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),
)
-1
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: {
-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")
+2 -2
View File
@@ -9,12 +9,12 @@ test("formats server and TUI plugins in sections without builtins", () => {
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "acme.dual",
source: { type: "package", target: "acme-plugin@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 },
},
-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 })
}
}
-8
View File
@@ -88,16 +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 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 update: PluginUpdateOperation<E>
}
export type SessionListInput = {
+1 -13
View File
@@ -15,8 +15,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -324,17 +322,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).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),
update: EndpointPluginUpdate(raw),
})
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
@@ -9,8 +9,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -468,19 +466,6 @@ export function make(options: ClientOptions) {
},
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) =>
+9 -21
View File
@@ -12,7 +12,7 @@ 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" }
@@ -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,15 +2582,6 @@ export type PluginListOutput = {
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
+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
@@ -44,17 +44,8 @@ export const Plugin = define({
catalog.model.default.set(configuredDefault.providerID, configuredDefault.model)
for (const [id, item] of configuredProviders(loaded.entries)) {
const providerID = id
const current = catalog.provider.get(providerID)
const source = catalog.provider.get(item.canonical ?? current?.provider.canonical ?? providerID)
const changed = item.canonical !== undefined && item.canonical !== current?.provider.canonical
catalog.provider.update(providerID, (provider) => {
if (changed && source && source.provider !== provider)
Object.assign(provider, structuredClone(source.provider), {
id: provider.id,
integrationID: provider.integrationID,
})
provider.activation = "enabled"
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
@@ -62,14 +53,7 @@ export const Plugin = define({
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
})
for (const [id, config] of Object.entries(item.models ?? {})) {
const base = source?.models.get(config.modelID ?? id) ?? source?.models.get(id)
const inherit = changed || !catalog.model.get(providerID, id)
catalog.model.update(providerID, id, (model) => {
if (inherit && base) {
Object.assign(model, structuredClone(base))
if (item.package !== undefined) model.package = undefined
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
}
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.modelID !== undefined) model.modelID = config.modelID
+4 -2
View File
@@ -7,6 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { ProjectMarkers } from "./project/markers.js"
import { AbsolutePath } from "./schema.js"
export const Kind = Schema.Literals(["file", "directory"])
@@ -80,6 +81,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const markers = yield* ProjectMarkers.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = resolvePath(location.directory, input.path)
@@ -111,7 +113,7 @@ const layer = Layer.effect(
resource: externalResource,
save: slash(
path.join(
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
(yield* Project.root(fs, AbsolutePath.make(externalDirectory), markers.targets())) ?? externalDirectory,
"*",
),
),
@@ -126,5 +128,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node],
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
})
+2
View File
@@ -10,6 +10,7 @@ export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
readonly vcsBackend?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
@@ -27,6 +28,7 @@ const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
vcs: resolved.vcs,
vcsBackend: resolved.vcsBackend,
})
}),
)
+2 -3
View File
@@ -132,7 +132,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
packageName,
settings: configured,
modelID: resolved.modelID ?? resolved.id,
providerID: resolved.canonical ?? resolved.providerID,
providerID: resolved.providerID,
})
: undefined
const native = mapping?.package ?? resolved.package
@@ -161,7 +161,6 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
)
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
@@ -170,7 +169,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.canonical ?? resolved.providerID,
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
+3 -3
View File
@@ -38,10 +38,10 @@ export function compatibility(input: unknown): Compatibility | undefined {
}
export function parse(input: string): { providerID: Provider.ID; modelID: ID } {
const index = input.indexOf("/")
const [providerID, ...modelID] = input.split("/")
return {
providerID: Provider.ID.make(index === -1 ? input : input.slice(0, index)),
modelID: ID.make(index === -1 ? "" : input.slice(index + 1)),
providerID: Provider.ID.make(providerID),
modelID: ID.make(modelID.join("/")),
}
}
+9 -13
View File
@@ -30,7 +30,7 @@ import { Permission } from "./permission.js"
export interface Interface {
readonly activate: (
plugins: readonly Generation[],
plugins: readonly Versioned[],
failures?: readonly Failure[],
) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
@@ -38,8 +38,8 @@ export interface Interface {
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
export type Generation = PluginDefinition & {
readonly revision: string
export type Versioned = PluginDefinition & {
readonly version: string
readonly source?: Plugin.Source
readonly features?: Plugin.Features
}
@@ -52,11 +52,11 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const kv = yield* KV.Service
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<PluginDefinition["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Generation) {
const load = Effect.fnUntraced(function* (plugin: Versioned) {
const child = yield* Scope.fork(scope)
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() =>
@@ -83,7 +83,7 @@ const layer = Layer.effect(
})
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly Generation[],
plugins: readonly Versioned[],
failures: readonly Failure[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
@@ -99,14 +99,10 @@ const layer = Layer.effect(
active.size === definitions.length &&
Array.from(active.values()).every((entry, index) => {
const definition = definitions[index]
return entry.plugin.id === definition?.id && entry.plugin.revision === definition.revision
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
})
) {
for (const definition of definitions) {
const entry = active.get(definition.id)
if (entry) active.set(definition.id, { ...entry, plugin: definition })
}
const nextInventory = [...definitions.map(activeInfo), ...failures]
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
@@ -178,7 +174,7 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Generation): Plugin.Info {
function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
+10 -7
View File
@@ -3,7 +3,7 @@ export * as InstancePlugins from "./instance.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Generation } from "../plugin.js"
import type { Versioned } from "../plugin.js"
/**
* Holds the plugins one instance is born with. Unlike the host-global
@@ -13,13 +13,16 @@ import type { Generation } from "../plugin.js"
* for the instance's lifetime; runtime dynamism lives inside plugins through
* the container transform/reload APIs.
*
* Config plugin operations may disable instance plugins by id, matching
* `SdkPlugins` behavior.
* Limitations: `vcs` marker declarations in an instance list are not seen by
* `ProjectMarkers` (it is global and runs during project resolution, before
* the instance exists unlike `SdkPlugins`, whose declarations it consumes
* directly), and config plugin operations may disable instance plugins by id,
* matching `SdkPlugins` behavior.
*/
export type List = readonly Plugin[]
export interface Interface {
readonly all: () => readonly Generation[]
readonly all: () => readonly Versioned[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstancePlugins") {}
@@ -30,8 +33,8 @@ export const node = makeLocationNode({
deps: [],
})
// The constant revision is load-bearing: the plugin registry treats an
// unchanged (id, revision) pair as the same plugin across activations, which
// The constant version is load-bearing: the plugin registry treats an
// unchanged (id, version) pair as the same plugin across activations, which
// is only correct because a bound list never changes after creation.
// `source: "sdk"` means host-contributed; an instance list is the
// per-instance form of the same channel.
@@ -41,7 +44,7 @@ export function bound(plugins: List) {
throw new Error(`duplicate instance plugin ids: ${duplicates.map((plugin) => plugin.id).join(", ")}`)
}
const stamped = plugins.map(
(plugin): Generation => ({ ...plugin, revision: "instance", source: { type: "sdk" } }),
(plugin): Versioned => ({ ...plugin, version: "instance", source: { type: "sdk" } }),
)
return Layer.succeed(Service, Service.of({ all: () => stamped }))
}
+5
View File
@@ -26,6 +26,7 @@ import { ConfigShellPlugin } from "../config/plugin/shell.js"
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
@@ -96,6 +97,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const command = yield* Command.Service
const config = yield* Config.Service
const credential = yield* Credential.Service
const pluginSources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
@@ -139,6 +141,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Command.Service, command),
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(ConfigPluginSource.Service, pluginSources),
Context.make(Bus.Service, bus),
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
@@ -189,6 +192,7 @@ export const requirements = LayerNode.group([
Command.node,
Config.node,
Credential.node,
ConfigPluginSource.node,
Bus.node,
Environment.node,
FileMutation.node,
@@ -283,6 +287,7 @@ export const list = Effect.fn("PluginInternal.list")(function* () {
plugins.map(
(plugin): Plugin => ({
id: plugin.id,
vcs: plugin.vcs,
effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
}),
)
+14 -15
View File
@@ -8,17 +8,23 @@ import { readdir } from "node:fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Generation } from "../plugin.js"
import type { Versioned } from "../plugin.js"
import { PluginPromise } from "./promise.js"
const Discovery = Schema.Struct({
id: Schema.optional(Schema.String),
markers: Schema.Array(Schema.String),
})
const Definition = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
vcs: Schema.optional(Discovery),
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
}),
Schema.Struct({
id: Schema.String,
vcs: Schema.optional(Discovery),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
@@ -28,17 +34,13 @@ const Definition = Schema.Struct({
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
options?: { readonly install?: boolean },
) {
const npm = yield* Npm.Service
const local = path.isAbsolute(operation.target)
const installed: Npm.EntryPoint = local
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
: options?.install === false
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const installed = local
? { entrypoint: pathToFileURL(operation.target).href }
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
@@ -61,16 +63,13 @@ export const load = Effect.fn("PluginModule.load")(function* (
return {
id: plugin.id,
features,
revision: JSON.stringify([operation, installed.revision]),
vcs: plugin.vcs,
version: JSON.stringify(operation),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: {
type: "package" as const,
target: operation.target,
...(installed.version ? { version: installed.version } : {}),
},
: { type: "package" as const, package: operation.target },
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Generation
} satisfies Versioned
})
function localFeatures(entrypoint: string) {
+78 -80
View File
@@ -6,14 +6,17 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
import { ConfigProviderV1 } from "../../v1/config/provider.js"
import { Money } from "@opencode-ai/schema/money"
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options.js"
import { ConfigV1 } from "../../v1/config/config.js"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({ providers: Schema.Record(Schema.String, ConfigProvider.Info) })
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
@@ -86,7 +89,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false
let providers: typeof RemoteResponse.Type.providers | undefined
let providers: typeof ConfigV1.Info.Type.provider | undefined
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
@@ -114,73 +117,59 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
yield* load()
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) {
const source = catalog.provider.get(item.canonical ?? providerID)
catalog.provider.update(providerID, (provider) => {
if (source && source.provider !== provider)
Object.assign(provider, structuredClone(source.provider), { id: provider.id })
provider.integrationID = Integration.ID.make("opencode")
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
provider.package = item.package ?? provider.package
provider.settings = Provider.mergeOverlay(
withoutCredentials(provider.settings),
withoutCredentials(item.settings),
)
provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
provider.body = Provider.mergeOverlay(provider.body, item.body)
provider.package = item.npm ? Provider.aisdk(item.npm) : ""
provider.settings = {
...provider.settings,
...withoutCredentials(item.options),
...(item.api ? { baseURL: item.api } : {}),
}
provider.headers = { ...provider.headers, ...item.options?.headers }
})
for (const [modelID, config] of Object.entries(item.models ?? {})) {
const base = source?.models.get(config.modelID ?? modelID) ?? source?.models.get(modelID)
catalog.model.update(providerID, modelID, (model) => {
Object.assign(model, structuredClone(base ?? model))
if (config.family !== undefined) model.family = config.family
if (config.family !== undefined) model.family = Model.Family.make(config.family)
if (config.name !== undefined) model.name = config.name
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
model.package = config.package ?? (item.package !== undefined ? undefined : model.package)
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
if (config.capabilities !== undefined)
model.capabilities = {
...config.capabilities,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
model.settings = Provider.mergeOverlay(
withoutCredentials(model.settings),
withoutCredentials(config.settings),
)
model.headers = Provider.mergeHeaders(model.headers, config.headers)
model.body = Provider.mergeOverlay(model.body, config.body)
for (const variant of config.variants ?? []) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = { id: variant.id }
model.variants.push(existing)
}
if (variant.settings !== undefined)
existing.settings = Provider.mergeOverlay(existing.settings, withoutCredentials(variant.settings))
if (variant.headers !== undefined)
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
if (variant.body !== undefined)
existing.body = Provider.mergeOverlay(
existing.body,
variantBody(variant.body, model.package ?? item.package ?? source?.provider.package),
)
if (config.id !== undefined) model.modelID = Model.ID.make(config.id)
model.compatibility = Model.compatibility(config.interleaved) ?? model.compatibility
if (config.provider !== undefined) {
model.package = config.provider.npm ? Provider.aisdk(config.provider.npm) : undefined
if (config.provider.api) model.settings = { ...model.settings, baseURL: config.provider.api }
}
if (config.cost !== undefined)
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
},
}))
model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output]
model.headers = { ...model.headers, ...config.headers }
model.settings = { ...model.settings, ...ConfigProviderOptionsV1.model(withoutCredentials(config.options)) }
if (config.variants !== undefined) {
model.variants ??= []
for (const [id, options] of Object.entries(config.variants)) {
const variantID = Model.VariantID.make(id)
let existing = model.variants.find((item) => item.id === variantID)
if (!existing) {
existing = { id: variantID }
model.variants.push(existing)
}
existing.headers = { ...existing.headers, ...options.headers }
existing.settings = {
...existing.settings,
...ConfigProviderOptionsV1.model(withoutCredentials(options)),
}
}
}
if (config.release_date !== undefined) {
const released = Date.parse(config.release_date)
model.time.released = Number.isFinite(released) ? released : 0
}
if (config.cost !== undefined) {
model.cost = remoteCost(config.cost)
}
model.status = config.status ?? "active"
model.enabled = config.status !== "deprecated"
if (config.limit !== undefined) model.limit = { ...config.limit }
})
}
}
@@ -219,7 +208,7 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
const token = value.type === "oauth" ? value.access : value.key
return http
.execute(
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
HttpClientRequest.get(`${server}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
@@ -230,29 +219,14 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.map((remote) => remote.providers),
Effect.map((remote) => remote.config.provider),
)
}),
)
}
function variantBody(body: Readonly<Record<string, unknown>>, packageName: string | undefined) {
if (packageName !== Provider.aisdk("@ai-sdk/openai")) return body
const { reasoningEffort, reasoningSummary, ...native } = body
const reasoning = {
...(typeof reasoningEffort === "string" ? { effort: reasoningEffort } : {}),
...(typeof reasoningSummary === "string" ? { summary: reasoningSummary } : {}),
}
if (Object.keys(reasoning).length === 0) return body
// Existing Console variants stored SDK options here before V2 consumed raw bodies.
return Provider.mergeOverlay({ reasoning }, native)
}
function withoutCredentials<Value>(body: Readonly<Record<string, Value>> | undefined) {
return (
body &&
Object.fromEntries(Object.entries(body).filter(([key]) => !["apiKey", "authToken", "accessToken"].includes(key)))
)
function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined) {
return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers"))
}
function normalizeServer(input: unknown) {
@@ -268,6 +242,30 @@ function normalizeServer(input: unknown) {
})
}
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
const base = {
input: Money.USDPerMillionTokens.make(input.input),
output: Money.USDPerMillionTokens.make(input.output),
cache: {
read: Money.USDPerMillionTokens.make(input.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.cache_write ?? 0),
},
}
if (!input.context_over_200k) return [base]
return [
base,
{
tier: { type: "context" as const, size: 200_000 },
input: Money.USDPerMillionTokens.make(input.context_over_200k.input),
output: Money.USDPerMillionTokens.make(input.context_over_200k.output),
cache: {
read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0),
},
},
]
}
function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
Effect.gen(function* () {
+4 -4
View File
@@ -4,7 +4,7 @@ import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import type { Generation } from "../plugin.js"
import type { Versioned } from "../plugin.js"
export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
@@ -21,7 +21,7 @@ export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
*/
export interface Interface {
readonly register: (plugin: Plugin) => Effect.Effect<void>
readonly all: () => readonly Generation[]
readonly all: () => readonly Versioned[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
@@ -30,12 +30,12 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const plugins = new Map<string, Generation>()
const plugins = new Map<string, Versioned>()
let revision = 0
return Service.of({
register: (plugin) =>
Effect.sync(() => {
plugins.set(plugin.id, { ...plugin, revision: String(++revision), source: { type: "sdk" } })
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
all: () => [...plugins.values()],
})
+4 -7
View File
@@ -3,11 +3,10 @@
export * as SkillPlugin from "./skill.js"
import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { Effect } from "effect"
import { AbsolutePath } from "../schema.js"
import { Skill } from "../skill.js"
import { Config } from "../config.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import os from "os"
import opencodeContent from "./skill/opencode.md" with { type: "text" }
import reportContent from "./skill/report.md" with { type: "text" }
@@ -69,11 +68,9 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
})
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
const config = yield* Config.Service
return (yield* config.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => entry.info.plugins ?? [])
.map((entry) => (typeof entry === "string" ? entry : entry.package))
const sources = yield* ConfigPluginSource.Service
return (yield* sources.operations())
.map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target))
.toSorted()
})

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