Compare commits

...

25 Commits

Author SHA1 Message Date
Aiden Cline 0828939c96 refactor(tui): inline session-missing tab close 2026-09-22 23:52:37 -05:00
Aiden Cline 3f04aa3491 fix(tui): only close session tabs when the session is missing 2026-09-22 23:38:51 -05:00
Aiden Cline 68b28bdb98 fix(codemode): coerce match/search patterns, allow any for...in target, bind the last duplicate parameter (#50802) 2026-09-22 23:32:27 -05:00
opencode-agent[bot] 150dc69e4b chore: update nix node_modules hashes 2026-09-23 04:29:53 +00:00
Vladimir Glafirov d5d4461e67 chore: bump gitlab-ai-provider to 6.16.0 (#50318) 2026-09-22 23:13:04 -05:00
Aiden Cline d56ce74373 fix(client): throw declared API errors as Error instances (#50788) 2026-09-22 21:25:28 -05:00
Aiden Cline 17abc5906b feat(codemode): add tagged templates and String.raw (#50791) 2026-09-22 21:25:21 -05:00
Aiden Cline 8656838a5b fix(ai): ignore bare null SSE frames (#50793) 2026-09-22 21:23:09 -05:00
Dax 53179daefa feat(core): order projects by recent activity (#50790) 2026-09-23 01:59:32 +00:00
Aiden Cline bab26d63ea docs(codemode): correct the interpreter support matrix (#50789) 2026-09-22 20:49:54 -05:00
Aiden Cline f0381e5da3 fix(core): normalize AI SDK fragment boundaries (#50685) 2026-09-22 20:27:34 -05:00
Aiden Cline 740072694d fix: show API error messages in remaining CLI and TUI paths (#50783) 2026-09-22 20:16:45 -05:00
Aiden Cline 2e4abeb25d fix(tui): show API error messages in toasts (#50778) 2026-09-22 20:02:32 -05:00
Aiden Cline 43f1dad8e1 fix(core): log error messages for MCP OAuth and credential failures (#50767) 2026-09-22 19:35:34 -05:00
OpeOginni cf4b4c2312 fix(tui): export complete session transcript (#50733) 2026-09-22 19:24:57 -05:00
Luke Parker 3bf8a5a8cf fix(app): keep Console sign-in visible when a Zen API key is stored (#50763) 2026-09-23 10:19:54 +10:00
Aiden Cline ddeb19790a fix(ai): replay Kimi reasoning details without the streaming index (#50383) 2026-09-22 19:10:57 -05:00
opencode-agent[bot] fe0d1682ca fix(tui): show latest step in turn token summary (#50765)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-22 18:56:52 -05:00
opencode-agent[bot] 1746672c42 chore: update nix node_modules hashes 2026-09-22 23:11:50 +00:00
Shoubhit Dash 3a2203eaac fix(core): install git plugins from branch subdirectories (#50754) 2026-09-23 04:21:36 +05:30
Shoubhit Dash 38c320ea4c fix(acp): forward provider retry status to clients (#50752) 2026-09-23 04:11:23 +05:30
opencode-agent[bot] 5c53cfc342 feat(session): allow metadata updates (#50025)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-22 17:27:38 -05:00
James Long 8683406690 feat(theme): support dynamic hue names (#50728) 2026-09-22 17:34:38 -04:00
Frank 126294a322 docs(www): merge Console documentation updates 2026-09-22 17:29:38 -04:00
opencode-agent[bot] d2bbefbac8 fix(ci): avoid stale Bun dependency caches (#49845)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-22 16:30:15 -04:00
94 changed files with 1662 additions and 396 deletions
+2 -4
View File
@@ -53,8 +53,6 @@ runs:
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
@@ -66,9 +64,9 @@ runs:
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --linker hoisted ${{ inputs.install-flags }}
bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }}
else
bun install ${{ inputs.install-flags }}
bun install --frozen-lockfile ${{ inputs.install-flags }}
fi
shell: bash
+3 -2
View File
@@ -367,7 +367,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1",
"gitlab-ai-provider": "6.16.0",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
@@ -1075,6 +1075,7 @@
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
@@ -4127,7 +4128,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.16.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-HMC3sKgWYaYSsgm86Cnq2e6laHlYkhiFQ6rFD5qsVghv9//6h4Ofr7j5R2KjQx9Hl8EBercPsmzjoEjEN7dX5Q=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-WBGj4wU5PQs+0zbDbScFhJs0GL8qWnswIsJb/Kee5Qc=",
"aarch64-linux": "sha256-Z8hkyNMdqCpuv24q50QhO2RECINvENjBhLDY5FKcnFc=",
"aarch64-darwin": "sha256-HZHb9HcXMOc7bKR7eADQSyfWxX37GAcoRi/EMSpRiP4=",
"x86_64-darwin": "sha256-CcWCOAcW4t9ADs48fMhumSbDcQWdk91LpkG6fe/gWlk="
"x86_64-linux": "sha256-DVimx64BQEkFaYZkW56E5eEfkGxuW5j/9C0wCImBFl4=",
"aarch64-linux": "sha256-MhMl/ZkvcPXE31OvysutI6nKIJ6XtUa3c71+B4hnaQc=",
"aarch64-darwin": "sha256-+wMPYLBnw2zTZj0if8EPwWC9O3tVqSgnhYNfQq8UZ8Q=",
"x86_64-darwin": "sha256-l6LxVSXDbqVoXMVcGZAzRER0bxP6wDU4dfRDolx0JGM="
}
}
+1 -1
View File
@@ -173,7 +173,7 @@
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
+120 -29
View File
@@ -1,4 +1,4 @@
import { Effect, Schema } from "effect"
import { Effect, Option, Schema } from "effect"
import { Tool } from "@opencode/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -76,6 +76,44 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
})
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
// `reasoning_details` carries two dialects. OpenRouter's `reasoning.*` entries
// must be replayed unmodified (`index` included), so they keep every field they
// arrived with. Kimi's OpenAI-compatible surface streams preserved thinking as
// bare `summary` / `encrypted` entries keyed by a stream-only `index`; Kimi does
// not document this publicly, so the handling follows Kimi Code (Kimi's own
// client): merge summary deltas by `index`, replay without `index`, and always
// send `reasoning_content` alongside. Anything else is dropped at the boundary.
const OpenRouterDetailFields = {
id: Schema.optional(Schema.NullOr(Schema.String)),
format: Schema.optional(Schema.String),
index: Schema.optional(Schema.Number),
signature: Schema.optional(Schema.NullOr(Schema.String)),
}
const ReasoningDetail = Schema.Union([
Schema.StructWithRest(
Schema.Struct({ type: Schema.Literal("reasoning.text"), text: Schema.optional(Schema.String), ...OpenRouterDetailFields }),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.Literal("reasoning.summary"),
summary: Schema.optional(Schema.String),
...OpenRouterDetailFields,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.StructWithRest(
Schema.Struct({ type: Schema.Literal("reasoning.encrypted"), data: Schema.String, ...OpenRouterDetailFields }),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.Struct({ type: Schema.Literal("summary"), summary: Schema.String, index: Schema.optional(Schema.Number) }),
Schema.Struct({ type: Schema.Literal("encrypted"), encrypted: Schema.String, index: Schema.optional(Schema.Number) }),
])
type ReasoningDetail = Schema.Schema.Type<typeof ReasoningDetail>
const decodeReasoningDetail = Schema.decodeUnknownOption(ReasoningDetail)
const knownReasoningDetails = (details: ReadonlyArray<unknown>) =>
details.flatMap((detail) => Option.toArray(decodeReasoningDetail(detail)))
// Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
// extension until direct Google OpenAI-compatible routing is supported here:
// https://github.com/vercel/ai/issues/11590
@@ -265,7 +303,9 @@ export interface ParserState {
readonly finishReason?: FinishReasonDetails
readonly lifecycle: Lifecycle.State
readonly reasoningField?: string
readonly reasoningDetails: Array<unknown>
/** A scalar reasoning field (`reasoning_content`, ...) has carried text in this stream. */
readonly reasoningTextObserved: boolean
readonly reasoningDetails: Array<ReasoningDetail>
readonly reasoningDetailsObserved: boolean
readonly reasoningEmitted: boolean
readonly latestToolIndex?: number
@@ -341,10 +381,21 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown,
return Array.isArray(details) ? details : []
})
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
return observed
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
return knownReasoningDetails(observed).map(lowerReasoningDetail)
if (isRecord(native) && Array.isArray(native.reasoning_details))
return knownReasoningDetails(native.reasoning_details).map(lowerReasoningDetail)
}
// Kimi rejects its stream-only `index` on requests
// ("the reasoning_details ... must not contain streaming index").
const lowerReasoningDetail = (detail: ReasoningDetail) => {
if (detail.type === "summary") return { type: detail.type, summary: detail.summary }
if (detail.type === "encrypted") return { type: detail.type, encrypted: detail.encrypted }
return detail
}
const isKimiDetail = (detail: { readonly type: string }) => detail.type === "summary" || detail.type === "encrypted"
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
@@ -410,6 +461,9 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
if (observedField !== undefined) return observedField
if (nativeReasoning !== undefined) return "reasoning_content"
if (!fullyStructured || requireReasoning) return "reasoning_content"
// Kimi always expects `reasoning_content` on replayed assistant messages,
// even when thinking arrived only through structured details.
if (details?.some(isKimiDetail)) return "reasoning_content"
})()
const reasoningText = (() => {
if (configuredField !== undefined)
@@ -881,44 +935,74 @@ const reasoningDelta = (
return undefined
}
const detailText = (details: ReadonlyArray<unknown>) => {
const detailText = (details: ReadonlyArray<ReasoningDetail>, hideKimiSummary: boolean) => {
const text = details.flatMap((detail) => {
if (!isRecord(detail)) return []
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
return [detail.summary]
if (detail.type === "reasoning.text") return detail.text ? [detail.text] : []
if (detail.type === "reasoning.summary") return detail.summary ? [detail.summary] : []
// Kimi streams the full thinking through `reasoning_content` and a separate
// summary through details; show the summary only when nothing else does.
if (detail.type === "summary") return detail.summary && !hideKimiSummary ? [detail.summary] : []
return []
})
if (text.length > 0) return text.join("")
}
const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
const appendReasoningDetails = (result: Array<ReasoningDetail>, details: ReadonlyArray<ReasoningDetail>) => {
for (const detail of details) {
const previous = result.at(-1)
if (
!isRecord(previous) ||
previous.type !== "reasoning.text" ||
!isRecord(detail) ||
detail.type !== "reasoning.text" ||
conflictingReasoningTextDetails(previous, detail)
) {
const merged = previous === undefined ? undefined : mergeReasoningDetails(previous, detail)
if (merged === undefined) {
result.push(detail)
continue
}
result[result.length - 1] = {
...previous,
...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
signature: mergeDetailValue(previous.signature, detail.signature),
format: mergeDetailValue(previous.format, detail.format),
}
result[result.length - 1] = merged
}
}
const mergeDetailValue = (previous: unknown, current: unknown) =>
// Consecutive text or summary deltas of the same kind accumulate into one
// entry; encrypted entries are opaque and never merge.
const mergeReasoningDetails = (previous: ReasoningDetail, detail: ReasoningDetail): ReasoningDetail | undefined => {
if (conflictingReasoningDetails(previous, detail)) return undefined
if (previous.type === "reasoning.text" && detail.type === "reasoning.text")
return {
...previous,
...detail,
text: `${previous.text ?? ""}${detail.text ?? ""}`,
...mergeDetailIdentity(previous, detail),
}
if (previous.type === "reasoning.summary" && detail.type === "reasoning.summary")
return {
...previous,
...detail,
summary: `${previous.summary ?? ""}${detail.summary ?? ""}`,
...mergeDetailIdentity(previous, detail),
}
if (previous.type === "summary" && detail.type === "summary")
return { ...previous, ...detail, summary: previous.summary + detail.summary }
}
type DetailIdentity = {
readonly id?: string | null
readonly index?: number
readonly format?: string
readonly signature?: string | null
}
// The first non-empty signature and format win; a later delta may carry the
// signature for text that streamed earlier.
const mergeDetailIdentity = (previous: DetailIdentity, current: DetailIdentity) => {
const signature = mergeDetailValue(previous.signature, current.signature)
const format = mergeDetailValue(previous.format, current.format)
return {
...(signature === undefined ? {} : { signature }),
...(format === undefined ? {} : { format }),
}
}
const mergeDetailValue = <T>(previous: T | undefined, current: T | undefined) =>
previous || current || (previous !== undefined ? previous : current)
const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
const conflictingReasoningDetails = (previous: DetailIdentity, current: DetailIdentity) =>
conflictingDetailValue(previous.id, current.id) ||
conflictingDetailValue(previous.index, current.index) ||
conflictingDetailValue(previous.format, current.format) ||
@@ -930,7 +1014,7 @@ const conflictingDetailValue = (previous: unknown, current: unknown) =>
const reasoningMetadata = (
providerMetadataKey: string,
field: ParserState["reasoningField"],
details?: ReadonlyArray<unknown>,
details?: ReadonlyArray<ReasoningDetail>,
) => ({
[providerMetadataKey]: {
...(field ? { reasoningField: field } : {}),
@@ -993,11 +1077,16 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
}
const reasoningField = state.reasoningField ?? reasoning?.field
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
const reasoningTextObserved = state.reasoningTextObserved || reasoning !== undefined
const detailDelta = Array.isArray(delta?.reasoning_details)
? knownReasoningDetails(delta.reasoning_details)
: undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField)
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
const text = detailDelta?.length
? (detailText(detailDelta, reasoningTextObserved) ?? reasoning?.text)
: reasoning?.text
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if (
reasoningDetailsObserved &&
@@ -1093,6 +1182,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
finishReason,
lifecycle,
reasoningField,
reasoningTextObserved,
reasoningDetails: state.reasoningDetails,
reasoningDetailsObserved,
reasoningEmitted,
@@ -1173,6 +1263,7 @@ export const protocol = Protocol.make({
toolCallEvents: [],
lifecycle: Lifecycle.initial(),
reasoningField: request.model.compatibility?.reasoningField,
reasoningTextObserved: false,
reasoningDetails: [],
reasoningDetailsObserved: false,
reasoningEmitted: false,
+9 -5
View File
@@ -231,11 +231,11 @@ export const errorText = (error: unknown) => {
/**
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, optionally filters named events, and drops empty events. `[DONE]`
* is dropped by default or retained for protocols that use it as their stream
* boundary. Retry control events are ignored without interrupting the stream.
* Decoder failures become provider output errors so the public error channel
* stays `AIError`.
* decoder, optionally filters named events, and drops empty and bare `null`
* events. `[DONE]` is dropped by default or retained for protocols that use it
* as their stream boundary. Retry control events are ignored without
* interrupting the stream. Decoder failures become provider output errors so
* the public error channel stays `AIError`.
*/
export const sseFraming = (
bytes: Stream.Stream<Uint8Array, AIError>,
@@ -265,6 +265,10 @@ export const sseFraming = (
(event) =>
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
// Some OpenAI-compatible proxies serialize an empty flush as a bare
// `data: null`, between events or after `[DONE]`. No protocol has a
// null event, so it carries nothing and must not abort the stream.
event.data !== "null" &&
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
),
Stream.map((event) => event.data),
+184 -3
View File
@@ -1182,7 +1182,9 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves unknown reasoning details while using scalar display text", () =>
// Only recognized detail shapes are retained and replayed; echoing an
// undocumented provider payload is what breaks follow-up requests.
it.effect("drops unknown reasoning details while using scalar display text", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } }]
const response = yield* LLMClient.generate(request).pipe(
@@ -1199,16 +1201,195 @@ describe("OpenAI Chat route", () => {
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
openai: { reasoningField: "reasoning", reasoningDetails: [] },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: [] },
])
}),
)
// Kimi's coding endpoint streams the full thinking through `reasoning_content`
// and a separate summary + encrypted blob through its own `reasoning_details`
// dialect. The stream-only `index` must not be echoed back.
it.effect("merges Kimi summary deltas by index and replays details without the streaming index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_content: "Let me" } }] },
{ choices: [{ delta: { reasoning_content: " think" } }] },
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: "Plan" }] } }] },
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: " tools" }] } }] },
{ choices: [{ delta: { reasoning_details: [{ index: 1, type: "encrypted", encrypted: "opaque" }] } }] },
{
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } },
],
},
},
],
},
{ choices: [{ delta: {}, finish_reason: "tool_calls" }] },
),
),
),
)
const stored = [
{ index: 0, type: "summary", summary: "Plan tools" },
{ index: 1, type: "encrypted", encrypted: "opaque" },
]
expect(response.reasoning).toBe("Let me think")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning_content", reasoningDetails: stored },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: null,
tool_calls: [{ id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } }],
reasoning_content: "Let me think",
reasoning_details: [
{ type: "summary", summary: "Plan tools" },
{ type: "encrypted", encrypted: "opaque" },
],
},
])
}),
)
it.effect("displays Kimi summaries and replays reasoning_content when no scalar reasoning streams", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: "Plan" }] } }] },
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: " tools" }] } }] },
{ choices: [{ delta: { reasoning_details: [{ index: 1, type: "encrypted", encrypted: "opaque" }] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("Plan tools")
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: "Hello",
reasoning_content: "Plan tools",
reasoning_details: [
{ type: "summary", summary: "Plan tools" },
{ type: "encrypted", encrypted: "opaque" },
],
},
])
}),
)
// Sessions persisted before the fix already hold Kimi details with `index`.
it.effect("strips the streaming index from previously stored Kimi details", () =>
Effect.gen(function* () {
const replay = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "thinking",
providerMetadata: {
openai: {
reasoningField: "reasoning_content",
reasoningDetails: [
{ index: 0, type: "summary", summary: "thinking" },
{ index: 1, type: "encrypted", encrypted: "opaque" },
],
},
},
},
]),
],
}),
)
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: "",
reasoning_content: "thinking",
reasoning_details: [
{ type: "summary", summary: "thinking" },
{ type: "encrypted", encrypted: "opaque" },
],
},
])
}),
)
it.effect("merges consecutive OpenRouter summary deltas and replays them unmodified", () =>
Effect.gen(function* () {
const merged = [
{ type: "reasoning.summary", summary: "Plan tools", format: "openai-responses-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 },
]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
choices: [
{
delta: {
reasoning_details: [
{ type: "reasoning.summary", summary: "Plan", format: "openai-responses-v1", index: 0 },
],
},
},
],
},
{
choices: [
{
delta: {
reasoning_details: [
{ type: "reasoning.summary", summary: " tools", format: "openai-responses-v1", index: 0 },
],
},
},
],
},
{ choices: [{ delta: { reasoning_details: [merged[1]] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("Plan tools")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: merged },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: merged }])
}),
)
it.effect("uses scalar display text for signature-only reasoning details", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
@@ -375,6 +375,27 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("ignores bare null frames between deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ role: "assistant", content: "Hello" }),
"null",
deltaChunk({ content: "!" }),
"null",
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("Hello!")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
}),
)
it.effect("accepts nullable usage and preserves provider fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -393,6 +393,33 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("ignores bare null frames between events", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
"null",
{ type: "response.output_text.delta", output_index: 0, item_id: "msg_1", delta: "Hello" },
"null",
{ type: "response.output_item.done", output_index: 0, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
"null",
),
),
),
)
expect(response.text).toBe("Hello")
expect(response.events.at(-1)).toMatchObject({ type: "finish" })
}),
)
describe("stream validation", () => {
const request = LLM.request({
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
+10 -3
View File
@@ -315,10 +315,9 @@ describe("OpenRouter", () => {
}),
)
it.effect("preserves opaque and duplicate continuation details", () =>
it.effect("drops unrecognized details and preserves duplicate continuation details", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
]
@@ -330,7 +329,15 @@ describe("OpenRouter", () => {
Message.assistant({
type: "reasoning",
text: "Thinking",
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
providerMetadata: {
openrouter: {
reasoningField: "reasoning",
reasoningDetails: [
{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
...details,
],
},
},
}),
],
}),
+14
View File
@@ -178,6 +178,20 @@ describe("AI.Usage", () => {
}),
)
it.effect("sseFraming drops bare null frames and keeps other payloads", () =>
Effect.gen(function* () {
const frames = yield* ProviderShared.sseFraming(
Stream.make(
new TextEncoder().encode(
'data: {"first":true}\n\ndata: null\n\nevent: update\ndata: null\n\ndata: "null"\n\ndata: 0\n\ndata: {"second":true}\n\ndata: [DONE]\n\ndata: null\n\n',
),
),
).pipe(Stream.runCollect)
expect(Array.from(frames)).toEqual(['{"first":true}', '"null"', "0", '{"second":true}'])
}),
)
test("visibleOutputTokens clamps reasoning > output to zero", () => {
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
@@ -0,0 +1,94 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/Projects/providers-console-signin"
const project = {
id: "proj_providers_console",
canonical: directory,
name: "Providers console",
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
// Zen and the Console account share the id `opencode`: the provider comes from models.dev, the
// integration is renamed by OpencodePlugin and carries the account sign-in.
const zen = (paid: boolean) => ({
id: "opencode",
name: "OpenCode Zen",
models: {
"claude-sonnet-4-6": {
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
cost: { input: paid ? 3 : 0, output: 0 },
},
},
})
const integrations = (connections: unknown[]) => [
{
id: "opencode",
name: "OpenCode Console",
methods: [
{ id: "device", type: "oauth", label: "OpenCode Console account" },
{ type: "key", label: "API key (service account)" },
],
connections,
},
{ id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] },
{ id: "anthropic", name: "Anthropic", methods: [{ type: "key" }], connections: [] },
]
test.use({ viewport: { width: 1280, height: 900 } })
async function openProviders(page: Page, input: { paid: boolean; connections: unknown[] }) {
await mockOpenCodeServer(page, {
directory,
project,
provider: { all: [zen(input.paid)], connected: ["opencode"], default: {} },
integrations: integrations(input.connections),
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Popular providers" })).toBeVisible()
return settings
}
test("fresh install offers the Console sign-in", async ({ page }) => {
const settings = await openProviders(page, { paid: false, connections: [] })
await expect(settings.getByText("No connected providers")).toBeVisible()
await expect(settings.getByText("OpenCode Console", { exact: true })).toBeVisible()
})
test("a stored Zen API key keeps the Console sign-in available", async ({ page }) => {
const settings = await openProviders(page, {
paid: true,
connections: [{ type: "credential", id: "cred_v1", label: "API key", method: "key" }],
})
const connected = settings.locator('[data-component="connected-providers-section"]')
await expect(connected.getByText("OpenCode Zen", { exact: true })).toBeVisible()
await expect(settings.getByText("OpenCode Console", { exact: true })).toBeVisible()
})
test("a Console account hides the sign-in row", async ({ page }) => {
const settings = await openProviders(page, {
paid: true,
connections: [{ type: "credential", id: "cred_account", label: "Clara Team", method: "oauth" }],
})
const connected = settings.locator('[data-component="connected-providers-section"]')
await expect(connected.getByText("OpenCode Zen", { exact: true })).toBeVisible()
// Anthropic only exists in the integration fixture, so its row proves the integration list has loaded.
await expect(settings.getByText("Anthropic", { exact: true })).toBeVisible()
await expect(settings.getByText("OpenCode Console", { exact: true })).toHaveCount(0)
})
+5 -2
View File
@@ -9,6 +9,7 @@ export interface MockServerConfig {
server?: string
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
integrations?: unknown[]
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
shells?: unknown[]
configEntries?: unknown[]
@@ -258,11 +259,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }),
modelDefault: () =>
Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }),
integrationList: () => Effect.succeed({ location: location(config), data: [] }),
integrationList: () => Effect.succeed({ location: location(config), data: config.integrations ?? [] }),
integrationGet: (ctx) =>
Effect.succeed({
location: location(config),
data: {
data: config.integrations
?.filter(record)
.find((integration) => integration.id === ctx.params.integrationID) ?? {
id: ctx.params.integrationID,
name: ctx.params.integrationID,
methods: config.integrationMethods?.[ctx.params.integrationID] ?? [{ type: "key", label: "API key" }],
@@ -93,7 +93,7 @@ describe("normalizeProviderList", () => {
describe("normalizeProjectInfo", () => {
test("keeps the project VCS backend", () => {
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1, active: 1 }, sandboxes: [] }
expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git")
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
@@ -124,7 +124,7 @@ describe("updateProjectInfo", () => {
canonical: "/repo",
name: "Repo",
icon: { color: "purple" },
time: { created: 1, updated: 2 },
time: { created: 1, updated: 2, active: 2 },
sandboxes: ["/repo-sandbox"],
} satisfies Project
@@ -99,13 +99,13 @@ describe("readLocalImage", () => {
test.each([400, 401])("propagates declared API errors (%s)", async (status) => {
const error = { _tag: "RequestError", message: "Cannot read image" }
const { api } = setup(() => Response.json(error, { status }))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject(error)
})
test("propagates missing-file errors", async () => {
const error = { _tag: "FileNotFoundError", path: "image.png", message: "File not found: image.png" }
const { api } = setup(() => Response.json(error, { status: 404 }))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject(error)
})
test("does not turn an unexpected HTTP status into a Blob", async () => {
@@ -8,6 +8,7 @@ import { useIntegrations } from "@/providers/catalog/integrations"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { CONSOLE_INTEGRATION } from "@/providers/connect/controller"
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
import { SettingsList } from "@/settings/list"
import "@/settings/settings.css"
@@ -62,9 +63,17 @@ export const SettingsProviders: Component<{
const popular = createMemo(() => {
const connectedIDs = new Set(connected().map((p) => p.id))
// The Console account (integration `opencode`) shares its id with the Zen provider. A stored API
// key, including one imported from a v1 auth.json, makes Zen "connected" without any account, so
// the Popular list keeps the sign-in row until the active credential is an OAuth grant. Until the
// integration list arrives the row is still the models.dev Zen provider, so dedupe it as before.
const console = integrations.list().find((entry) => entry.id === CONSOLE_INTEGRATION)
const items = providers
.popular()
.filter((p) => !connectedIDs.has(p.id))
.filter((p) => {
if (p.id !== CONSOLE_INTEGRATION || !console) return !connectedIDs.has(p.id)
return console.connections.find((connection) => connection.type === "credential")?.method !== "oauth"
})
.slice()
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
return items
@@ -24,7 +24,7 @@ test("settings project inventory reads metadata without acquiring directory stor
name: `Project ${index}`,
icon: { color: "orange" },
commands: { start: "bun install" },
time: { created: 1, updated: 1 },
time: { created: 1, updated: 1, active: 1 },
sandboxes: [],
worktrees: [],
}))
@@ -107,7 +107,7 @@ describe("withWorktreeInventory", () => {
id: "project",
canonical: "/repo",
name: "Before",
time: { created: 1, updated: 1 },
time: { created: 1, updated: 1, active: 1 },
sandboxes: [],
}
+25 -1
View File
@@ -4,6 +4,7 @@ import type {
OpenCodeClient,
SessionMessageAssistant,
SessionMessageInfo,
SessionStructuredError,
} from "@opencode/client/promise"
import type { ACPConnection } from "./connection"
import { partsToContentChunks, type ReplayPart } from "./content"
@@ -40,6 +41,13 @@ export type TurnStart =
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
export const ChildSessionUpdateMethod = "opencode/session/child_update"
const RetryMeta = "opencode/retry"
type RetryStatus = {
readonly attempt: number
readonly nextRetryAt: string
readonly error: SessionStructuredError
}
type ChildSessionUpdateBase = {
readonly rootSessionId: string
@@ -97,6 +105,7 @@ export async function streamTurn(input: {
let finish: SessionMessageAssistant["finish"]
let executionError: { readonly type: string; readonly message: string } | undefined
const tools = new Map<string, ToolState>()
const retries = new Map<string, RetryStatus>()
const children = new Map<string, ChildSession>()
const openChildren = new Set<string>()
let handedOff = false
@@ -188,6 +197,18 @@ export async function streamTurn(input: {
if (event.type === "session.step.started") {
if (!child) assistantMessageID = event.data.assistantMessageID
if (retries.delete(eventSessionID))
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } })
continue
}
if (event.type === "session.retry.scheduled") {
const retry = {
attempt: event.data.attempt,
nextRetryAt: new Date(event.data.at).toISOString(),
error: event.data.error,
}
retries.set(eventSessionID, retry)
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } })
continue
}
if (event.type === "session.text.delta") {
@@ -381,6 +402,7 @@ export async function streamTurn(input: {
terminal,
control.cancelled,
finish,
retries.get(input.sessionID),
)
} catch (error) {
streamController.abort()
@@ -560,6 +582,7 @@ function response(
terminal: "succeeded" | "failed" | "interrupted",
cancelled: boolean,
finish: SessionMessageAssistant["finish"],
retry?: RetryStatus,
): PromptResponse {
const error = assistant?.error ?? executionError
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
@@ -582,7 +605,8 @@ function response(
}
: undefined
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
return { stopReason, ...(usage ? { usage } : {}), _meta: {} }
// Only an interrupt during backoff leaves a retry pending. Interruption clears the projected retry, so report it here.
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
}
function resolveStopReason(input: {
+2 -1
View File
@@ -2,6 +2,7 @@ import type { Endpoint } from "@opencode/client/effect/service"
import { Effect } from "effect"
import path from "node:path"
import { Standalone } from "../services/standalone"
import { errorMessage } from "../util/error"
import { reportRunError, runNonInteractiveWithOptions, type RunCommandInput } from "./run"
export type V1RunCommandInput = {
@@ -53,7 +54,7 @@ export function runV1Bridge(input: V1RunCommandInput) {
)
}),
),
).catch((error) => reportRunError(input, error instanceof Error ? error.message : String(error)))
).catch((error) => reportRunError(input, errorMessage(error)))
}
function nativeInput(input: V1RunCommandInput, endpoint: Endpoint): RunCommandInput {
+6 -11
View File
@@ -1,5 +1,6 @@
import type { LocationGetOutput, ModelRef, OpenCodeClient, SessionInfo } from "@opencode/client/promise"
import { Model } from "@opencode/schema/model"
import { errorMessage } from "./util/error"
const SESSION_PAGE_LIMIT = 50
@@ -24,7 +25,7 @@ export class SessionTargetMutationError extends Error {
override readonly name = "SessionTargetMutationError"
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : "Session target mutation failed", { cause })
super(errorMessage(cause), { cause })
}
}
@@ -125,11 +126,9 @@ async function selectSession(input: {
if (!selected) return { session: undefined, location }
return {
session: input.fork
? await input.client.session
.fork({ sessionID: selected.id }, ...requestOptions(input.signal))
.catch((error) => {
throw new SessionTargetMutationError(error)
})
? await input.client.session.fork({ sessionID: selected.id }, ...requestOptions(input.signal)).catch((error) => {
throw new SessionTargetMutationError(error)
})
: selected,
}
}
@@ -156,11 +155,7 @@ async function latestSession(
return latestSession(client, location, page.cursor.next, signal)
}
function resolveLocation(
client: OpenCodeClient,
location?: { directory?: string },
signal?: AbortSignal,
) {
function resolveLocation(client: OpenCodeClient, location?: { directory?: string }, signal?: AbortSignal) {
if (!location && !signal) return client.location.get()
if (!location) return client.location.get(undefined, { signal })
return client.location.get({ location }, ...requestOptions(signal))
@@ -0,0 +1,35 @@
import type { PromptResponse, SessionNotification } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
describe("acp retry subprocess", () => {
test("reports provider retries during the turn and on cancellation", async () => {
await using fixture = await createAcpFixture({
respond: () =>
Response.json(
{ error: { message: "rate-limited upstream", type: "rate_limit_error" } },
// A long backoff keeps the retry pending until the cancel lands.
{ status: 429, headers: { "retry-after": "30" } },
),
})
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const prompt = acp.send<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hello" }],
})
const scheduled = await acp.waitForNotification<SessionNotification>(
"session/update",
(params) => params.sessionId === session.sessionId && params.update.sessionUpdate === "session_info_update",
)
const retry = scheduled.params.update._meta?.["opencode/retry"]
expect(retry).toMatchObject({ attempt: 2, error: { message: expect.stringContaining("rate-limited upstream") } })
await acp.notify("session/cancel", { sessionId: session.sessionId })
const response = expectOk(await prompt.response)
expect(response.stopReason).toBe("cancelled")
expect(response._meta?.["opencode/retry"]).toEqual(retry)
}, 60_000)
})
+7 -2
View File
@@ -70,7 +70,10 @@ description: Verifier compatibility skill.
`
export async function createAcpFixture(
options: { readonly skill?: string; readonly respond?: (request: unknown) => string | Promise<string> } = {},
options: {
readonly skill?: string
readonly respond?: (request: unknown) => string | Response | Promise<string | Response>
} = {},
) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
const home = path.join(root, "workspace")
@@ -93,7 +96,9 @@ export async function createAcpFixture(
}
const body: unknown = await request.json().catch(() => undefined)
requests.push(body)
return new Response(completion(await (options.respond?.(body) ?? "accepted")), {
const result = await (options.respond?.(body) ?? "accepted")
if (result instanceof Response) return result
return new Response(completion(result), {
headers: { "content-type": "text/event-stream" },
})
},
+15
View File
@@ -241,6 +241,7 @@ export type SessionSwitchModelOperation<E = never> = (
export type SessionUpdateInput = {
readonly sessionID: Session.ID
readonly title?: string | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
}
export type SessionUpdateOutput = void
@@ -516,6 +517,20 @@ export type SessionLogOutput =
| undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.metadata.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?:
| {
readonly directory: AbsolutePath
readonly workspaceID?: (string & Brand.Brand<"Workspace.ID">) | undefined
}
| undefined
readonly data: { readonly sessionID: Session.ID; readonly metadata: Session.Metadata }
}
| {
readonly id: Event.ID
readonly created: number
@@ -453,7 +453,7 @@ const EndpointSessionUpdate = (raw: RawClient["server.session"]) => (input: Sess
preserveEffect<SessionUpdateOutput>()(
raw["session.update"]({
params: { sessionID: input["sessionID"] },
payload: { title: input["title"], permissions: input["permissions"] },
payload: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -330,7 +330,8 @@ export function make(options: ClientOptions) {
}
const responseError = async (response: Response, descriptor: RequestDescriptor): Promise<never> => {
if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)
if (descriptor.declaredStatuses.includes(response.status))
throw declared((await json(response)) as DeclaredErrorBody)
try {
await response.body?.cancel()
} catch {}
@@ -665,7 +666,7 @@ export function make(options: ClientOptions) {
{
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
body: { title: input["title"], permissions: input["permissions"] },
body: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
@@ -2200,6 +2201,19 @@ async function json(response: Response): Promise<unknown> {
}
}
type DeclaredErrorBody = {
readonly _tag?: string
readonly message?: string
readonly data?: { readonly message?: string }
}
/** Throw declared error bodies as Errors. The body's fields stay on the error, so narrowing on `_tag` or `name` still works. */
function declared(body: DeclaredErrorBody) {
const error = Object.assign(new Error(body.message ?? body.data?.message), body)
if (body._tag) error.name = body._tag
return error
}
function isContentType(response: Response, expected: string) {
return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected
}
+22 -1
View File
@@ -296,7 +296,7 @@ export type ProjectIcon = { url?: string; override?: string; color?: string }
export type ProjectCommands = { start?: string }
export type ProjectTime = { created: number; updated: number }
export type ProjectTime = { created: number; updated: number; active: number }
export type PermissionSource = { type: "tool"; messageID: string; id: string }
@@ -1221,6 +1221,16 @@ export type SessionMoved = {
export type SessionInboxMovePayload1 = { location: LocationRef; projectID: string; subpath?: string }
export type SessionMetadataUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.metadata.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; metadata: SessionMetadata }
}
export type SessionShellStarted = {
id: string
created: number
@@ -2308,6 +2318,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionMetadataUpdated
| SessionPermissions
| SessionViewed
| SessionDeleted
@@ -2370,6 +2381,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionMetadataUpdated
| SessionPermissions
| SessionViewed
| SessionUsageUpdated
@@ -3946,12 +3958,21 @@ export type SessionUpdateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly title?: {
readonly title?: string | undefined
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
readonly permissions?:
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
| undefined
}["title"]
readonly metadata?: {
readonly title?: string | undefined
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
readonly permissions?:
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
| undefined
}["metadata"]
readonly permissions?: {
readonly title?: string | undefined
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
readonly permissions?:
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
| undefined
+1 -1
View File
@@ -1788,7 +1788,7 @@ export function createData(config: CreateDataInput) {
},
project: {
list() {
return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated)
return Object.values(store.project.info).toSorted((a, b) => b.time.active - a.time.active)
},
get(projectID: string) {
return store.project.info[projectID]
+40
View File
@@ -151,6 +151,46 @@ test("vcs.diff exposes unavailable comparisons as errors, not empty diffs", asyn
})
})
test("declared errors are thrown as Error instances that keep the body", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json(
{ _tag: "InvalidRequestError", message: "Incompatible auth server", kind: "integration_authorization" },
{ status: 400 },
),
})
const error = await client.integration.oauth
.connect({ integrationID: "mcp_test", methodID: "oauth", location: { directory: "/repo" } })
.then(
() => undefined,
(cause: unknown) => cause,
)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toBe("Incompatible auth server")
expect(error.name).toBe("InvalidRequestError")
expect(error.stack).toContain("InvalidRequestError: Incompatible auth server")
expect(error).toMatchObject({ _tag: "InvalidRequestError", kind: "integration_authorization" })
})
test("declared errors with a data envelope read the nested message", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json({ name: "WorktreeError", data: { message: "Worktree directory unavailable" } }, { status: 400 }),
})
const error = await client.worktree.create({ projectID: "prj_test" }).then(
() => undefined,
(cause: unknown) => cause,
)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toBe("Worktree directory unavailable")
expect(error.name).toBe("WorktreeError")
expect(error).toMatchObject({ name: "WorktreeError", data: { message: "Worktree directory unavailable" } })
})
test("project.update uses the global project contract", async () => {
let request: Request | undefined
const project = {
+29 -14
View File
@@ -41,8 +41,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] A trailing comma after a rest parameter is a syntax error, with or without `"use strict"`.
- [x] A program that begins with `"use strict"` rejects `yield` as an identifier and duplicate parameter names at
parse time. Without it, `yield` is an ordinary binding.
- [ ] Duplicate parameter names in non-strict code throw when the function is called, instead of binding the last
parameter as JavaScript does.
- [x] Duplicate parameter names in non-strict code bind the last parameter, as in JS (`function f(a, a)` called
with `(1, 2)` sees `a === 2`).
## Values and literals
@@ -52,12 +52,15 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
- [x] Template literals with interpolation.
- [x] Tagged templates: a tag applied to a template literal is called as `tag(strings, ...values)`, with the tag read
like a callee so a member tag keeps its receiver. `strings` is an array of the cooked text with a read-only `raw`
array of the source text; an invalid escape such as `\unicode` cooks to `undefined`. One template object per
site, as in JS, but it is not frozen: `strings[0] = "x"` succeeds here where JS throws.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
`Symbol.asyncIterator` keys are available only for the iterator protocols.
- [ ] Tagged-template calls.
- [ ] Getter and setter definitions in object literals.
## Bindings and destructuring
@@ -86,7 +89,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
sources are rejected.
- [x] Destructuring reads through the prototype chain like member access: `const { constructor } = error` and
`const { slice } = values` find the inherited built-in.
- [ ] Member expressions as `for...in` targets (`for (x.y in obj)`).
- [x] Any assignment target as a `for...in` head, like `for...of`: `for (x.y in obj)`, `for (a[i++] in obj)`, and
destructuring patterns.
## Statements and control flow
@@ -201,10 +205,18 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
creates a hole without changing its length. Deleting a non-configurable property (`length`) or
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode. `delete` of a
non-reference (`delete 0`, `delete f()`) evaluates the operand and is `true`; `delete x` on a variable throws.
- [ ] Operators, `switch` discriminants, template interpolation, and coercion helpers such as `String` and `isNaN`
applied to functions and namespaces; JavaScript coerces them, the interpreter rejects non-data operands.
- [ ] ToPrimitive on object operands: operators, `Error(message)`, `Date` arguments, and `parseInt` radix should call
`valueOf`/`toString` in spec order and surface their throws.
- [x] Coercion helpers and template interpolation accept functions and namespaces: `String(fn)` and `${fn}` give
`"[object Function]"` rather than the source text, `isNaN(fn)` is `true`.
- [ ] Operators other than `===`/`!==`, `switch` discriminants and cases, and `Object.is` applied to a function,
promise, generator, tool reference, or any object holding one anywhere inside; JavaScript compares by identity or
coerces (`fn == null` is `false`, `fn + ""` is its source text), the interpreter throws
`TypeError: Binary operators require data values.` The check walks both operands' whole object graphs, so
`rows == null` on a large array is slow where `rows === null` is not.
- [ ] ToPrimitive on program objects: operators, `Number`/`String`, `Error(message)`, `parseInt` radix, multi-argument
`Date` construction and `Date.UTC`, and numeric built-in arguments (`Math.max`, `at`, `indexOf` start) should call
the object's own `valueOf`/`toString` in spec order and surface their throws. Today they use the built-in form
(`NaN`, `"[object Object]"`) and ignore own methods. Date setters and one-argument `Date` construction already
follow ToPrimitive.
- [x] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become
string keys.
@@ -343,10 +355,12 @@ reject }` object.
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
expressions with a native-style `TypeError`. Opaque runtime references still reject as data errors, and
`repeat` still requires a finite non-negative count.
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern. Present
arguments must still be a regular expression or string pattern.
- [ ] `String.raw`.
- [ ] `match`, `search`, and `split` accept any value and coerce it (objects via `toString`), like JavaScript.
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern.
- [x] `String.raw`, on a template object or any `{ raw }` object; raw strings and substitutions coerce through their own
`toString`.
- [x] `match`, `matchAll`, `search`, and `split` read any non-RegExp argument as a pattern string, as `new RegExp(arg)`
would: `"a1b".match(1)` matches `/1/`, `search(null)` looks for `"null"`, and `undefined` is the empty pattern.
Objects use their built-in string form until ToPrimitive lands.
## Numbers and Math
@@ -406,8 +420,9 @@ reject }` object.
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
- [ ] Date setters and multi-argument construction coerce object arguments through `valueOf`/`toString` and surface
their throws.
- [x] Date setters and one-argument construction coerce object arguments through their own `valueOf`/`toString` and
surface their throws.
- [ ] Multi-argument construction and `Date.UTC` coerce object arguments the same way (see ToPrimitive above).
- [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string
representation for the string primitive.
- [x] Native `RangeError` branding for invalid `toISOString()` calls.
@@ -32,6 +32,7 @@ import type {
Statement,
Super,
SwitchStatement,
TaggedTemplateExpression,
Literal,
TemplateLiteral,
ThrowStatement,
@@ -69,6 +70,7 @@ import {
assign,
Callable,
define,
frozen,
get,
hostCursor,
IteratorObj,
@@ -270,6 +272,8 @@ export class Interpreter<R> {
readonly pending: Pending<R>
readonly builtins: Builtins
readonly logs: Array<string>
/** Template objects by site: a tag sees the same `strings` array every time its literal is evaluated, as in JS. */
readonly templates = new WeakMap<TaggedTemplateExpression, Arr>()
private readonly root: Frame<R>
constructor(options: {
@@ -933,10 +937,10 @@ class Frame<R> {
const keys = self.enumerableKeys(right, node.right)
if (left.type !== "Identifier" && left.type !== "VariableDeclaration") {
if (left.type === "RestElement" || left.type === "AssignmentPattern") {
throw typeError("Unsupported for...in binding.", left)
}
const assignmentName = left.type === "Identifier" ? left.name : undefined
const assignment = left.type === "VariableDeclaration" ? undefined : left
for (const key of keys) {
const result = yield* Effect.gen(function* () {
@@ -946,8 +950,8 @@ class Frame<R> {
yield* self.declarePattern(declared.pattern, key, declared.mutable, left, true)
} else if (declared) {
yield* self.assignPattern(declared.pattern, key, left)
} else if (assignmentName) {
self.scopes.set(assignmentName, key, left)
} else if (assignment) {
yield* self.assignPattern(assignment, key, left)
}
return yield* self.evaluateStatement(node.body)
}).pipe(
@@ -1295,6 +1299,8 @@ class Frame<R> {
return this.evaluateArrayExpression(node)
case "TemplateLiteral":
return this.evaluateTemplateLiteral(node)
case "TaggedTemplateExpression":
return this.evaluateTaggedTemplate(node)
case "ConditionalExpression":
return this.evaluateConditionalExpression(node)
case "UpdateExpression":
@@ -1666,6 +1672,9 @@ class Frame<R> {
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
}
}
const parameters = fn.parameters.map((parameter) =>
parameter.type === "Identifier" ? parameter.name : undefined,
)
const bind = Effect.gen(function* () {
for (const [index, parameter] of fn.parameters.entries()) {
if (parameter.type === "RestElement") {
@@ -1678,6 +1687,8 @@ class Frame<R> {
)
break
}
// A sloppy simple parameter list may repeat a name; the last occurrence wins, as in JS.
if (parameter.type === "Identifier" && parameters.lastIndexOf(parameter.name) !== index) continue
yield* invocation.declarePattern(parameter, args[index], true, parameter, true)
}
})
@@ -2006,7 +2017,7 @@ class Frame<R> {
return Effect.gen(function* () {
for (let index = 0; index < quasis.length; index += 1) {
const quasi = quasis[index]!
// acorn only omits `cooked` for invalid escapes in tagged templates, which are unsupported.
// acorn only omits `cooked` for invalid escapes, which are a parse error outside a tagged template.
if (typeof quasi.value.cooked !== "string") {
throw typeError("Invalid template literal quasi.", quasi)
}
@@ -2024,6 +2035,41 @@ class Frame<R> {
})
}
// `tag\`a${x}b\`` is `tag(strings, x)`: the tag is read like a callee (a member keeps its receiver), the
// substitutions are evaluated in order, and `strings` carries the escaped source as `strings.raw`.
private evaluateTaggedTemplate(node: TaggedTemplateExpression): Effect.Effect<Value, unknown, R> {
const self = this
return Effect.gen(function* () {
const { callable, thisValue } =
node.tag.type === "MemberExpression"
? yield* self.readMethod(node.tag)
: { callable: yield* self.evaluateExpression(node.tag), thisValue: undefined }
const strings = self.ctx.templates.get(node) ?? self.createTemplateObject(node)
const values = yield* Effect.forEach(node.quasi.expressions, (expression) => self.evaluateExpression(expression))
return yield* self.call(callable, thisValue, [strings, ...values], node, node.tag)
})
}
private createTemplateObject(node: TaggedTemplateExpression): Arr {
const array = this.ctx.builtins.Array
// An invalid escape such as `\unicode` cooks to `undefined` and survives only in `raw`.
const strings = new Arr(
array,
node.quasi.quasis.map((quasi) => quasi.value.cooked ?? undefined),
)
define(
strings,
"raw",
new Arr(
array,
node.quasi.quasis.map((quasi) => quasi.value.raw),
),
frozen,
)
this.ctx.templates.set(node, strings)
return strings
}
private evaluateConditionalExpression(node: ConditionalExpression): Effect.Effect<Value, unknown, R> {
return Effect.flatMap(this.evaluateExpression(node.test), (test) =>
this.evaluateExpression(test ? node.consequent : node.alternate),
+1 -1
View File
@@ -72,7 +72,7 @@ export const uriError = failure("URIError")
// Orient the agent rather than enumerate JavaScript; interpreter-support.md is the full matrix.
export const supportedSyntaxMessage =
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols. Use plain functions and data objects instead."
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, BigInt, and custom Symbols. Use plain functions and data objects instead."
export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
new PendingThrow(
+8 -13
View File
@@ -33,21 +33,16 @@ const escapeRegexHint =
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
export const toHostRegex = (arg: Value, method: string, extraFlags = ""): RegExp => {
// Native parity: an undefined pattern behaves as an empty pattern.
if (arg === undefined) return new RegExp("", extraFlags)
if (arg instanceof RegExpObj) return arg.regex
if (typeof arg === "string") {
try {
return new RegExp(arg, extraFlags)
} catch (error) {
throw syntaxError(
`String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`,
)
}
// Anything else is a pattern string, as `new RegExp(arg)` would read it: undefined is the empty pattern.
const source = arg === undefined ? "" : coerceToString(arg)
try {
return new RegExp(source, extraFlags)
} catch (error) {
throw syntaxError(
`String.${method} received the string ${JSON.stringify(source)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`,
)
}
throw typeError(
`String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`,
)
}
export const matchToValue = (builtins: Builtins, match: RegExpMatchArray): Arr => {
+27 -1
View File
@@ -4,17 +4,20 @@ import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
import {
define,
get,
hidden,
Arr,
hostIterator,
Obj,
RegExpObj,
record,
coerceToInteger,
coerceToNumber,
coerceToString,
type Value,
} from "../interpreter/objects.js"
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
import { applyCollectionCallback, isSupportedCallback } from "../interpreter/callback.js"
import { applyCollectionCallback, isSupportedCallback, toPrimitiveString } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { matchToValue, toHostRegex } from "./regexp.js"
import { coercion } from "./value.js"
@@ -98,6 +101,29 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
methods(builtins, string, [
codeUnits("fromCharCode", String.fromCharCode),
codeUnits("fromCodePoint", String.fromCodePoint),
[
"raw",
1,
(_, args) => {
const template = args[0]
const raw = template instanceof Obj ? get(template, "raw") : undefined
if (!(raw instanceof Obj)) throw typeError("String.raw expects a template object with a raw array.")
const count = Math.max(0, coerceToInteger(get(raw, "length")))
checkArrayLength(count)
// Each literal is followed by its substitution, except the last literal, or when substitutions run out.
const parts = Array.from({ length: count }, (_, index) =>
index + 1 < count && index + 1 < args.length ? [get(raw, index), args[index + 1]] : [get(raw, index)],
).flat()
return Effect.map(
Effect.forEach(parts, (part) => toPrimitiveString(ctx, part)),
(strings) => {
const output = strings.join("")
checkStringLength(output.length)
return output
},
)
},
],
])
const self = (thisValue: Value, name: string): string => {
@@ -77,8 +77,6 @@ describe("new on a non-constructible callee", () => {
expect(failure.message).toStartWith(
"SyntaxError: Syntax 'ClassDeclaration' is not supported. This is a restricted JavaScript-like language. Supported: ",
)
expect(failure.message).toContain(
"Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols.",
)
expect(failure.message).toContain("Unsupported: classes, this, getters/setters, BigInt, and custom Symbols.")
})
})
+100 -1
View File
@@ -964,7 +964,7 @@ describe("coercion parity: unknown static members read as undefined", () => {
expect(await value(`return typeof Math.sum`)).toBe("undefined")
expect(await value(`return RegExp.quote === undefined`)).toBe(true)
expect(await value(`return Number.range === undefined`)).toBe(true)
expect(await value(`return String.raw === undefined`)).toBe(true)
expect(await value(`return String.dedent === undefined`)).toBe(true)
expect(await value(`return isFinite.something === undefined`)).toBe(true)
expect(await value(`return console.group === undefined`)).toBe(true)
expect(await value(`return Date.moment === undefined`)).toBe(true)
@@ -1079,3 +1079,102 @@ describe("functions are objects", () => {
).toEqual([3, ["count"], true, true, "fn", 3, true, true, null])
})
})
describe("tagged templates", () => {
test("the tag receives the cooked strings, their raw forms, and the substitutions in order", async () => {
expect(
await value(`
const tag = (strings, ...values) => [strings, strings.raw, values, Object.keys(strings)]
return tag\`a\${1}b\\n\${2}c\`
`),
).toEqual([
["a", "b\n", "c"],
["a", "b\\n", "c"],
[1, 2],
["0", "1", "2"],
])
})
test("an invalid escape cooks to undefined and keeps its raw text", async () => {
expect(await value(`return ((strings) => [strings[0] === undefined, strings.raw[0]])\`\\unicode\``)).toEqual([
true,
"\\unicode",
])
})
test("each site has one template object; different sites differ", async () => {
expect(
await value(`
const seen = []
const tag = (strings) => { seen.push(strings) }
for (let i = 0; i < 2; i++) tag\`x\${i}\`
tag\`x\${0}\`
return [seen[0] === seen[1], seen[0] === seen[2]]
`),
).toEqual([true, false])
})
test("the tag is read like a callee: members, chained tags, async tags, and the not-a-function error", async () => {
expect(
await value(`
const o = { tag: (strings) => strings[0].toUpperCase() }
const chain = () => chain
const asyncTag = async (strings, value) => strings[0] + value
let failure
try { (1)\`x\` } catch (error) { failure = error instanceof TypeError }
return [o.tag\`abc\`, typeof chain\`a\`\`b\`, await asyncTag\`n=\${1}\`, failure]
`),
).toEqual(["ABC", "function", "n=1", true])
})
test("raw is read-only", async () => {
expect(await value(`try { ((strings) => { strings.raw = 1 })\`a\` } catch (error) { return error.name }`)).toBe(
"TypeError",
)
})
})
describe("String.raw", () => {
test("joins the raw strings with the substitutions", async () => {
expect(await value(`return [String.raw\`a\\n\${1}b\`, String.raw({ raw: ["x", "y", "z"] }, 1, 2, 3)]`)).toEqual([
"a\\n1b",
"x1y2z",
])
})
test("extra substitutions are dropped, missing ones are skipped, and a program object's toString is used", async () => {
expect(
await value(`
const shout = { toString() { return "!" } }
return [String.raw({ raw: ["x", "y"] }, 1, 2), String.raw({ raw: ["x", "y", "z"] }, shout), String.raw({ raw: { length: 0 } })]
`),
).toEqual(["x1y", "x!yz", ""])
})
test("a template without a raw array is a TypeError", async () => {
const failure = await error(`String.raw(1)`)
expect(failure.message).toContain("String.raw expects a template object with a raw array")
})
})
describe("sloppy duplicate parameters and for...in targets", () => {
test("a repeated parameter name binds the last argument", async () => {
expect(await value(`function f(a, b, a) { return [a, b] } return [f(1, 2, 3), f(1)]`)).toEqual([
[3, 2],
[null, null],
])
})
test("for...in assigns to any target: members, computed members, and patterns", async () => {
expect(
await value(`
const x = {}, seen = [], a = []
let i = 0, first
for (x.y in { p: 1, q: 2 }) seen.push(x.y)
for (a[i++] in { p: 1, q: 2 });
for ([first] in { ab: 1 });
return [seen, x.y, a, first]
`),
).toEqual([["p", "q"], "q", ["p", "q"], "a"])
})
})
+6 -4
View File
@@ -452,10 +452,12 @@ describe("RegExp", () => {
expect((await error(`return "aa".matchAll(/a/)`)).message).toContain("write /a/g, or use String.match")
})
test("a non-pattern argument names the expected shapes", async () => {
const err = await error(`return "abc".match(42)`)
expect(err.message).toContain("expects a regular expression")
expect(err.message).toContain("not number")
test("any argument is a pattern string, as new RegExp(arg) reads it", async () => {
expect(
await value(
`return ["a42b".match(42)[0], "xnullx".search(null), "abc".match(undefined), [..."1a1".matchAll(1)].length]`,
),
).toEqual(["42", 1, [""], 2])
})
test("source and flags properties read through", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"revision": "250f204f23a9249ff204be2baec29600faae7b75",
"directories": ["built-ins/Array/prototype", "language/statements", "built-ins/Iterator"],
"directories": ["built-ins/Array/prototype", "built-ins/Iterator", "built-ins/String/raw", "language/expressions/tagged-template", "language/statements"],
"harness": ["assert.js", "sta.js", "compareArray.js", "doneprintHandle.js"],
"flags": ["module", "raw", "noStrict"],
"boundaries": {
+2 -1
View File
@@ -199,6 +199,8 @@ built-ins/Iterator/prototype/drop/limit-tonumber-throws.js # Expected a Test262
built-ins/Iterator/prototype/drop/limit-tonumber.js # Iterator.prototype.drop expects a non-negative count, received NaN.
built-ins/Iterator/prototype/take/limit-tonumber-throws.js # Expected a Test262Error but got a RangeError
built-ins/Iterator/prototype/take/limit-tonumber.js # Iterator.prototype.take expects a non-negative count, received NaN.
language/expressions/tagged-template/constructor-invocation.js # The called value cannot be constructed: user-defined constructors and classes are not supported.
language/expressions/tagged-template/template-object-frozen-strict.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/async-generator/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/async-generator/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/async-generator/return-undefined-implicit-and-explicit.js # Actual ["tick 1", "tick 2", "g1 ret", "g2 ret", "g3 ret", "g4 ret"] and expected ["tick 1", "g1 ret"
@@ -213,7 +215,6 @@ language/statements/for-await-of/async-gen-decl-dstr-obj-empty-num.js # TypeErr
language/statements/for-await-of/async-gen-decl-dstr-obj-empty-string.js # TypeError: Object destructuring requires a data object or array value, received a string.
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-number.js # TypeError: Object destructuring requires a data object or array value, received a number.
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-str-val.js # TypeError: Object destructuring requires a data object or array value, received a string.
language/statements/for-in/head-lhs-member.js # Unsupported for...in binding.
language/statements/for-of/dstr/array-elem-iter-rtrn-close-err.js # Iterator next must be a function.
language/statements/for-of/dstr/array-elem-iter-thrw-close-err.js # Expected SameValue(«1», «0») to be true
language/statements/for-of/dstr/array-elem-trlg-iter-list-thrw-close-err.js # Expected SameValue(«1», «0») to be true
+1 -1
View File
@@ -127,7 +127,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1",
"gitlab-ai-provider": "6.16.0",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
+12 -2
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
"id": "e98619ef-c334-45e3-8c48-9932ed1ef290",
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
"ddl": [
{
"name": "account_state",
@@ -700,6 +700,16 @@
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "time_active",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": true,
+39 -14
View File
@@ -705,7 +705,7 @@ function metadataProviderOptions(input: ProviderMetadata | undefined): SharedV3P
}
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions, http?: HttpMiddleware) {
const state = { step: 0, toolNames: {} as Record<string, string> }
const state: StreamState = { step: 0, toolNames: {}, open: {} }
return Stream.concat(
Stream.make(LLMEvent.stepStart({ index: state.step })),
Stream.unwrap(
@@ -731,8 +731,16 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
)
}
type Fragment = "text" | "reasoning"
type StreamState = {
step: number
toolNames: Record<string, string>
open: Partial<Record<Fragment, string>>
}
function streamPartEvents(
state: { step: number; toolNames: Record<string, string> },
state: StreamState,
event: LanguageModelV3StreamPart,
): Effect.Effect<ReadonlyArray<LLMEvent>, AIError> {
switch (event.type) {
@@ -744,11 +752,10 @@ function streamPartEvents(
case "tool-approval-request":
return Effect.succeed([])
case "text-start":
return Effect.succeed([
LLMEvent.textStart({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }),
])
return Effect.succeed(openFragment(state, "text", event.id, providerMetadata(event.providerMetadata)))
case "text-delta":
return Effect.succeed([
...openFragment(state, "text", event.id),
LLMEvent.textDelta({
id: event.id,
text: event.delta,
@@ -756,15 +763,12 @@ function streamPartEvents(
}),
])
case "text-end":
return Effect.succeed([
LLMEvent.textEnd({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }),
])
return Effect.succeed(closeFragment(state, "text", event.id, providerMetadata(event.providerMetadata)))
case "reasoning-start":
return Effect.succeed([
LLMEvent.reasoningStart({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }),
])
return Effect.succeed(openFragment(state, "reasoning", event.id, providerMetadata(event.providerMetadata)))
case "reasoning-delta":
return Effect.succeed([
...openFragment(state, "reasoning", event.id),
LLMEvent.reasoningDelta({
id: event.id,
text: event.delta,
@@ -772,9 +776,7 @@ function streamPartEvents(
}),
])
case "reasoning-end":
return Effect.succeed([
LLMEvent.reasoningEnd({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }),
])
return Effect.succeed(closeFragment(state, "reasoning", event.id, providerMetadata(event.providerMetadata)))
case "tool-input-start":
state.toolNames[event.id] = event.toolName
return Effect.succeed([
@@ -851,6 +853,29 @@ function streamPartEvents(
}
}
// Session persists one open text and one open reasoning fragment at a time, while AI SDK providers may overlap,
// repeat, or omit fragment boundaries. Like the native protocol lifecycles, a start or delta for another fragment
// closes the open one, repeated starts are ignored, and ends for fragments that are not open are dropped.
function openFragment(state: StreamState, kind: Fragment, id: string, providerMetadata?: ProviderMetadata) {
const open = state.open[kind]
if (open === id) return []
state.open[kind] = id
const start =
kind === "text" ? LLMEvent.textStart({ id, providerMetadata }) : LLMEvent.reasoningStart({ id, providerMetadata })
if (open === undefined) return [start]
return [fragmentEnd(kind, open), start]
}
function closeFragment(state: StreamState, kind: Fragment, id: string, providerMetadata?: ProviderMetadata) {
if (state.open[kind] !== id) return []
state.open[kind] = undefined
return [fragmentEnd(kind, id, providerMetadata)]
}
function fragmentEnd(kind: Fragment, id: string, providerMetadata?: ProviderMetadata) {
return kind === "text" ? LLMEvent.textEnd({ id, providerMetadata }) : LLMEvent.reasoningEnd({ id, providerMetadata })
}
function usage(input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["usage"]): UsageInput | undefined {
const output = {
inputTokens: input.inputTokens.total,
+2
View File
@@ -46,6 +46,7 @@ import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
import m47 from "./migration/20260923013825_project_time_active.js"
export const migrations = [
m00,
@@ -95,4 +96,5 @@ export const migrations = [
m44,
m45,
m46,
m47,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260923013825_project_time_active",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`project\` ADD \`time_active\` integer DEFAULT 0 NOT NULL;`)
yield* tx.run(`UPDATE \`project\` SET \`time_active\` = \`time_updated\`;`)
})
},
}
export default migration
+1
View File
@@ -110,6 +110,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`time_initialized\` integer,
\`time_active\` integer DEFAULT 0 NOT NULL,
\`sandboxes\` text NOT NULL,
\`commands\` text
);
@@ -523,8 +523,8 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, time_active, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
@@ -753,11 +753,11 @@ function importNextDatabase(
yield* tx.run(sql`
INSERT OR IGNORE INTO project (
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
time_created, time_updated, time_initialized, sandboxes, commands
time_created, time_updated, time_initialized, time_active, sandboxes, commands
) VALUES (
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
${project.time_initialized}, ${project.time_updated}, ${project.sandboxes}, ${project.commands}
)
`)
const existing = yield* tx
+18 -2
View File
@@ -1,13 +1,18 @@
export * as LocationLifecycle from "./location-lifecycle.js"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { LocationEvent } from "@opencode/schema/location-event"
import { Bus } from "./bus.js"
import { Form } from "./form.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
import { Permission } from "./permission.js"
import { Project } from "./project.js"
import { Rpc } from "./rpc.js"
import { SessionEvent } from "./session/event.js"
const isSessionEvent = Schema.is(SessionEvent.Durable)
export class Service extends Context.Service<
Service,
@@ -22,6 +27,17 @@ const layer = Layer.effect(
const permission = yield* Permission.Service
const forms = yield* Form.Service
const rpc = yield* Rpc.Service
const project = yield* Project.Service
const scope = yield* Effect.scope
yield* project.activate(location.project.id)
const unsubscribe = yield* bus.listen((event) => {
if (!isSessionEvent(event) || !event.location) return Effect.void
const ref = LocationServiceMap.canonical(event.location)
if (ref.directory !== location.directory || ref.workspaceID !== location.workspaceID) return Effect.void
// Listeners run inside publish; keep the write and its Project event off that path.
return project.activate(location.project.id).pipe(Effect.forkIn(scope), Effect.asVoid)
})
yield* Effect.addFinalizer(() => unsubscribe)
let closed = false
const shutdown = yield* Effect.cached(
Effect.gen(function* () {
@@ -48,5 +64,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Location.node, Permission.node, Form.node, Rpc.node],
deps: [Bus.node, Location.node, Permission.node, Project.node, Form.node, Rpc.node],
})
+24 -3
View File
@@ -2,7 +2,7 @@ export * as Project from "./project.js"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { and, asc, desc, eq, gte, isNull, lte } from "drizzle-orm"
import { and, asc, desc, eq, gte, isNull, lte, sql } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { Bus } from "./bus.js"
@@ -57,9 +57,13 @@ export const root = Effect.fn("Project.root")(function* (
)
})
const ACTIVATE_INTERVAL = 60_000
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
/** Records Project activity for recency ordering, at most once per minute per Project. */
readonly activate: (projectID: ID) => Effect.Effect<void>
/** Resolves and persists the owning Project. */
readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
}
@@ -85,6 +89,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info {
time: {
created: row.time_created,
updated: row.time_updated,
active: row.time_active,
},
sandboxes: row.sandboxes,
}
@@ -201,7 +206,7 @@ const layer = Layer.effect(
const rows = yield* db
.select()
.from(ProjectTable)
.orderBy(desc(ProjectTable.time_updated), asc(ProjectTable.id))
.orderBy(desc(ProjectTable.time_active), asc(ProjectTable.id))
.all()
.pipe(Effect.orDie)
return rows.map(fromRow)
@@ -233,6 +238,22 @@ const layer = Layer.effect(
return project
})
const activated = new Map<ID, number>()
const activate = Effect.fn("Project.activate")(function* (projectID: ID) {
const now = Date.now()
if (now - (activated.get(projectID) ?? 0) < ACTIVATE_INTERVAL) return
activated.set(projectID, now)
const row = yield* db
.update(ProjectTable)
// Activity is not a metadata edit, so keep time_updated out of the implicit bump.
.set({ time_active: now, time_updated: sql`${ProjectTable.time_updated}` })
.where(eq(ProjectTable.id, projectID))
.returning()
.get()
.pipe(Effect.orDie)
if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row))
})
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
@@ -361,7 +382,7 @@ const layer = Layer.effect(
})
})
return Service.of({ list, update, resolve })
return Service.of({ list, update, activate, resolve })
}),
)
+4
View File
@@ -19,6 +19,10 @@ export const ProjectTable = sqliteTable("project", {
icon_color: text(),
...Timestamps,
time_initialized: integer(),
time_active: integer()
.notNull()
.default(0)
.$defaultFn(() => Date.now()),
sandboxes: absoluteArrayColumn().notNull(),
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})
+5
View File
@@ -170,6 +170,10 @@ export interface Interface {
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly setMetadata: (input: {
sessionID: SessionSchema.ID
metadata: SessionSchema.Metadata
}) => Effect.Effect<void, NotFoundError>
readonly setPermissions: (input: {
sessionID: SessionSchema.ID
permissions: Permission.Ruleset
@@ -417,6 +421,7 @@ const layer = Layer.effect(
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),
setMetadata: (input) => sessions.forSession(input.sessionID).setMetadata(input),
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
move: moves.move,
compact: (input) => sessions.forSession(input.sessionID).compact(input),
@@ -131,6 +131,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
}),
"session.renamed": () => Effect.void,
"session.metadata.updated": () => Effect.void,
"session.permissions": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
+8
View File
@@ -573,6 +573,14 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.MetadataUpdated, (event) =>
db
.update(SessionTable)
.set({ metadata: event.data.metadata, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Permissions, (event) =>
db
.update(SessionTable)
+10
View File
@@ -73,6 +73,13 @@ export const make = Effect.fn("Session.make")(function* () {
yield* get(sessionID)
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
})
const setMetadata = Effect.fn("Session.setMetadata")(function* (
sessionID: SessionSchema.ID,
input: { metadata: SessionSchema.Metadata },
) {
yield* get(sessionID)
yield* bus.publish(SessionEvent.MetadataUpdated, { sessionID, metadata: input.metadata })
})
const setPermissions = Effect.fn("Session.setPermissions")(function* (
sessionID: SessionSchema.ID,
input: { permissions: Permission.Ruleset },
@@ -342,6 +349,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setMetadata,
setPermissions,
switchAgent,
switchModel,
@@ -365,6 +373,7 @@ export const make = Effect.fn("Session.make")(function* () {
const message = operations.message.bind(undefined, sessionID)
const view = operations.view.bind(undefined, sessionID)
const rename = operations.rename.bind(undefined, sessionID)
const setMetadata = operations.setMetadata.bind(undefined, sessionID)
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
const switchModel = operations.switchModel.bind(undefined, sessionID)
@@ -391,6 +400,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setMetadata,
setPermissions,
switchAgent,
switchModel,
+4 -2
View File
@@ -6,15 +6,16 @@ const decode = Schema.decodeUnknownOption(
Schema.Struct({
name: Schema.optional(Schema.String),
_tag: Schema.optional(Schema.String),
message: Schema.optional(Schema.String),
code: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
errno: Schema.optional(Schema.Number),
cause: Schema.optional(Schema.Unknown),
}),
)
/** Error messages, stacks and SQL parameters may contain credentials. Retain only diagnostic classifications. */
/** Stacks are omitted; each entry keeps the classification and message of one error in the cause chain. */
export function from(error: unknown) {
const errors: { type: string; code?: string | number; errno?: number }[] = []
const errors: { type: string; message?: string; code?: string | number; errno?: number }[] = []
const seen = new Set<unknown>()
while (error && !seen.has(error) && errors.length < 8) {
seen.add(error)
@@ -22,6 +23,7 @@ export function from(error: unknown) {
if (Option.isNone(result)) break
errors.push({
type: result.value._tag ?? (error instanceof Error ? error.name : result.value.name) ?? "unknown",
message: result.value.message || undefined,
code: result.value.code,
errno: result.value.errno,
})
+84
View File
@@ -385,6 +385,90 @@ it.effect("routes AI Gateway model options by upstream prefix", () =>
}),
)
it.effect("closes the open AI SDK reasoning part when the next one starts", () =>
Effect.gen(function* () {
// AI SDK OpenAI Responses can start summary part 1 before part 0 ends, then end both at item completion (#50662).
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = {
languageModel: () =>
streamModel([
{ type: "reasoning-start", id: "rs_1:0", providerMetadata: { gateway: { generationId: "gen_1" } } },
{ type: "reasoning-start", id: "rs_1:1" },
{ type: "reasoning-delta", id: "rs_1:1", delta: "Second summary" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { gateway: { encrypted: "late" } } },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { gateway: { encrypted: "final" } } },
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage },
]),
}
})
const resolved = yield* aisdk.model(model("@ai-sdk/gateway"))
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Think" })).pipe(
Effect.provide(client),
)
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{ type: "reasoning-start", id: "rs_1:0", providerMetadata: { gateway: { generationId: "gen_1" } } },
{ type: "reasoning-end", id: "rs_1:0" },
{ type: "reasoning-start", id: "rs_1:1", providerMetadata: undefined },
{ type: "reasoning-delta", id: "rs_1:1", text: "Second summary", providerMetadata: undefined },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { gateway: { encrypted: "final" } } },
])
}),
)
it.effect("normalizes repeated, reopened, and overlapping AI SDK fragment boundaries", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = {
languageModel: () =>
streamModel([
// Older xAI Responses repeat the start for every summary part.
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", delta: "First" },
{ type: "reasoning-end", id: "rs_1" },
// xAI Chat keeps streaming an ended reasoning id after an empty tool_calls chunk.
{ type: "reasoning-delta", id: "rs_1", delta: "Second" },
{ type: "reasoning-end", id: "rs_1" },
// xAI Responses ends every message item only when the stream flushes.
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", delta: "One" },
{ type: "text-start", id: "msg_2" },
{ type: "text-delta", id: "msg_2", delta: "Two" },
{ type: "text-end", id: "msg_1" },
{ type: "text-end", id: "msg_2" },
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage },
]),
}
})
const resolved = yield* aisdk.model(model("@ai-sdk/gateway"))
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Think" })).pipe(
Effect.provide(client),
)
expect(
response.events.filter((event) => event.type.startsWith("reasoning-") || event.type.startsWith("text-")),
).toMatchObject([
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "First" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "Second" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "One" },
{ type: "text-end", id: "msg_1" },
{ type: "text-start", id: "msg_2" },
{ type: "text-delta", id: "msg_2", text: "Two" },
{ type: "text-end", id: "msg_2" },
])
}),
)
it.effect("projects replay metadata onto AI SDK prompt parts", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
@@ -100,6 +100,7 @@ describe("node build", () => {
return Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
}),
+1
View File
@@ -13,6 +13,7 @@ export const globalProjectNode = makeGlobalNode({
return Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
resolve: (directory) => {
const project = { id: Project.ID.global, directory, canonical: directory }
return upsertProject(database.db, project).pipe(Effect.orDie, Effect.as(project))
+1
View File
@@ -14,6 +14,7 @@ const projectLayer = Layer.succeed(
Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
+10 -7
View File
@@ -248,20 +248,23 @@ describe("Npm.add", () => {
expect(entries.added.directory).toContain("node_modules")
})
test("installs a Git package from an npm ::path: subdirectory", async () => {
test.each(["commit", "branch"])("installs a Git package from an npm ::path: subdirectory at a %s", async (ref) => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const spec = `git+file://${fixture.repository}#${fixture.commit}::path:packages/subdirectory-plugin`
const entry = await Effect.gen(function* () {
const spec = `git+file://${fixture.repository}#${ref === "commit" ? fixture.commit : "fixture-branch"}::path:packages/subdirectory-plugin`
const result = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
return { added: yield* npm.add(spec), outdated: yield* npm.check(spec) }
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
expect(entry.name).toBe("fixture-subdirectory-plugin")
expect(result.added.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
expect(result.added.name).toBe("fixture-subdirectory-plugin")
expect(result.added.version).toBe(fixture.commit)
expect(await Bun.file(path.join(result.added.directory, "index.js")).text()).toContain("subdirectory: true")
expect(
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
await fs.stat(path.join(path.dirname(result.added.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
expect(result.outdated).toBeFalse()
})
// Several real Git installs and updates exceed Bun's default timeout on Windows.
+35 -9
View File
@@ -18,7 +18,7 @@ import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Project.node, Database.node, Bus.node])))
describe("Project.list", () => {
it.effect("returns complete projects ordered by recent update", () =>
it.effect("returns complete projects ordered by recent activity", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const project = yield* Project.Service
@@ -35,6 +35,7 @@ describe("Project.list", () => {
sandboxes: [abs("/older/sandbox")],
time_created: 1,
time_updated: 1,
time_active: 4,
},
{
id: Project.ID.make("newer"),
@@ -43,17 +44,12 @@ describe("Project.list", () => {
time_created: 2,
time_updated: 2,
time_initialized: 3,
time_active: 3,
},
])
.run()
expect(yield* project.list()).toEqual([
{
id: Project.ID.make("newer"),
canonical: abs("/newer"),
time: { created: 2, updated: 2 },
sandboxes: [],
},
{
id: Project.ID.make("older"),
canonical: abs("/older"),
@@ -61,9 +57,15 @@ describe("Project.list", () => {
name: "Older",
icon: { color: "#000000" },
commands: { start: "bun dev" },
time: { created: 1, updated: 1 },
time: { created: 1, updated: 1, active: 4 },
sandboxes: [abs("/older/sandbox")],
},
{
id: Project.ID.make("newer"),
canonical: abs("/newer"),
time: { created: 2, updated: 2, active: 3 },
sandboxes: [],
},
])
}),
)
@@ -83,6 +85,7 @@ describe("Project.update", () => {
sandboxes: [],
time_created: 1,
time_updated: 1,
time_active: 1,
})
.run()
@@ -111,13 +114,36 @@ describe("Project.update", () => {
expect((yield* project.list())[0]).toEqual({
id,
canonical: abs("/update"),
time: { created: 1, updated: expect.any(Number) },
time: { created: 1, updated: expect.any(Number), active: 1 },
sandboxes: [],
})
}),
)
})
describe("Project.activate", () => {
it.effect("records activity without editing metadata time and throttles repeats", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const project = yield* Project.Service
const id = Project.ID.make("activate")
yield* db
.insert(ProjectTable)
.values({ id, worktree: abs("/activate"), sandboxes: [], time_created: 1, time_updated: 1, time_active: 1 })
.run()
yield* project.activate(id)
const first = (yield* project.list())[0]
expect(first?.time.updated).toBe(1)
expect(first?.time.active).toBeGreaterThan(1)
yield* db.update(ProjectTable).set({ time_active: 2, time_updated: 1 }).run()
yield* project.activate(id)
expect((yield* project.list())[0]?.time).toEqual({ created: 1, updated: 1, active: 2 })
}),
)
})
function remoteID(remote: string) {
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
}
+21 -1
View File
@@ -8,7 +8,7 @@ import { Money } from "@opencode/schema/money"
import { Shell } from "@opencode/schema/shell"
import { Skill } from "@opencode/schema/skill"
import { Agent } from "@opencode/core/agent"
import { asc, eq } from "drizzle-orm"
import { and, asc, eq } from "drizzle-orm"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
@@ -385,6 +385,26 @@ describe("Session.create", () => {
// Absent stays absent: no empty-object normalization.
expect((yield* session.create({ location })).metadata).toBeUndefined()
const replacement = { thread: "updated", owner: "host" }
yield* session.setMetadata({ sessionID: created.id, metadata: replacement })
expect((yield* session.get(created.id)).metadata).toEqual(replacement)
expect(
yield* db
.select({ data: EventTable.data })
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, created.id),
eq(EventTable.type, Bus.versionedType(SessionEvent.MetadataUpdated.type, 1)),
),
)
.get()
.pipe(Effect.orDie),
).toMatchObject({ data: { metadata: replacement } })
expect(
yield* session.setMetadata({ sessionID: Session.ID.create(), metadata: replacement }).pipe(Effect.flip),
).toBeInstanceOf(Session.NotFoundError)
}),
)
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test"
import { ErrorSummary } from "../../src/util/error-summary"
describe("ErrorSummary", () => {
test("keeps the message and classification of each error in the cause chain", () => {
const root = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:443"), { code: "ECONNREFUSED", errno: -61 })
const error = new Error("Incompatible auth server: does not support dynamic client registration", { cause: root })
expect(ErrorSummary.from(error)).toEqual([
{ type: "Error", message: "Incompatible auth server: does not support dynamic client registration" },
{ type: "Error", message: "connect ECONNREFUSED 127.0.0.1:443", code: "ECONNREFUSED", errno: -61 },
])
})
test("omits the stack and empty messages", () => {
const summary = ErrorSummary.from(new Error(""))
expect(summary).toEqual([{ type: "Error" }])
expect(JSON.stringify(summary)).not.toContain("stack")
})
})
File diff suppressed because one or more lines are too long
@@ -1051,7 +1051,8 @@ describe("HttpApiCodegen.generate", () => {
})
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(error).toBeInstanceOf(Error)
expect(error).toMatchObject({ name: "Missing", message: "gone", _tag: "Missing" })
expect(emitted.module.isMissing(error)).toBeTrue()
})
+15 -1
View File
@@ -1556,6 +1556,16 @@
}
]
},
"metadata": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Metadata"
},
{
"type": "null"
}
]
},
"permissions": {
"anyOf": [
{
@@ -16308,9 +16318,13 @@
"updated": {
"type": "integer",
"minimum": 0
},
"active": {
"type": "integer",
"minimum": 0
}
},
"required": ["created", "updated"],
"required": ["created", "updated", "active"],
"additionalProperties": false
},
"Project.Vcs": {
+1
View File
@@ -359,6 +359,7 @@ export const makeSessionGroup = <
params: { sessionID: Session.ID },
payload: Schema.Struct({
title: Schema.String.pipe(Schema.optional),
metadata: Session.Metadata.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
+1
View File
@@ -32,6 +32,7 @@ export interface Commands extends Schema.Schema.Type<typeof Commands> {}
export const Time = Schema.Struct({
created: NonNegativeInt,
updated: NonNegativeInt,
active: NonNegativeInt,
}).annotate({ identifier: "Project.Time" })
export interface Time extends Schema.Schema.Type<typeof Time> {}
+11
View File
@@ -111,6 +111,16 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const MetadataUpdated = Event.durable({
type: "session.metadata.updated",
...options,
schema: {
...Base,
metadata: SessionMetadata,
},
})
export type MetadataUpdated = typeof MetadataUpdated.Type
export const Permissions = Event.durable({
type: "session.permissions",
...options,
@@ -648,6 +658,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
MetadataUpdated,
Permissions,
Viewed,
UsageUpdated,
+3 -3
View File
@@ -1,9 +1,9 @@
import { Schema } from "effect"
/**
* Host-supplied session annotations, durable from creation and opaque to
* core. Keys are arbitrary; values must be JSON-serializable. Children and
* forks inherit the parent's metadata unless the creator supplies its own.
* Host-supplied session annotations, durable and opaque to core. Keys are
* arbitrary; values must be JSON-serializable. Children and forks inherit
* the parent's current metadata unless the creator supplies its own.
*/
export const SessionMetadata = Schema.Record(Schema.String, Schema.Json).annotate({
identifier: "Session.Metadata",
@@ -115,6 +115,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.metadata.updated.1",
"session.permissions.1",
"session.viewed.1",
"session.message.content.updated.1",
+4
View File
@@ -266,6 +266,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
yield* title.generate(ctx.params.sessionID)
}
}
if (ctx.payload.metadata !== undefined)
yield* session
.setMetadata({ sessionID: ctx.params.sessionID, metadata: ctx.payload.metadata })
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
if (ctx.payload.permissions !== undefined)
yield* session
.setPermissions({ sessionID: ctx.params.sessionID, permissions: ctx.payload.permissions })
@@ -0,0 +1,38 @@
import { expect } from "bun:test"
import { Session } from "@opencode/schema/session"
import { Effect, Schema } from "effect"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
const SessionResponse = Schema.Struct({ data: Schema.toEncoded(Session.Info) })
it.live("updates session metadata through PATCH", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make({
app: { version: "test" },
database: { path: ":memory:" },
fs: { filewatcher: false },
models: { fetch: false },
})
const request = (path: string, method: string, body?: unknown) =>
Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local${path}`, {
method,
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}),
)
expect(response.status).toBe(method === "PATCH" ? 204 : 200)
return response.status === 204 ? undefined : response.json()
})
const created = Schema.decodeUnknownSync(SessionResponse)(
yield* request("/api/session", "POST", { metadata: { source: "create", stale: true } }),
)
yield* request(`/api/session/${created.data.id}`, "PATCH", { metadata: { source: "patch" } })
const updated = Schema.decodeUnknownSync(SessionResponse)(yield* request(`/api/session/${created.data.id}`, "GET"))
expect(updated.data.metadata).toEqual({ source: "patch" })
}).pipe(Effect.scoped),
)
-3
View File
@@ -1,3 +0,0 @@
import type { HueName } from "./schema.js"
export const DEFAULT_CATEGORICAL = ["blue", "purple", "green", "orange", "red", "cyan"] as const satisfies readonly HueName[]
+2 -4
View File
@@ -2,13 +2,11 @@ export {
ActionState,
type ActionStateKey,
ActionVariant,
BaseHue,
BaseThemeDefinition,
CategoricalDefinition,
FeedbackKind,
FormfieldState,
type FormfieldStateKey,
HueAlias,
HueName,
HueStep,
MarkdownDefinition,
@@ -24,6 +22,7 @@ export {
type FormfieldColorDefinition,
type HueDefinition,
type Mode,
type SemanticHue,
type StatefulColorDefinition,
type TextDefinition,
type ThemeTokensDefinition,
@@ -42,10 +41,9 @@ export type {
ResolvedThemeTokens,
StatefulColor,
} from "./types.js"
export { DEFAULT_CATEGORICAL } from "./categorical.js"
export { rgbToOklch } from "./color.js"
export { expandTheme } from "./expand.js"
export { migrateV1 } from "./v1-migrate.js"
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
export { parseThemeDocument, resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
export { generateSyntax } from "./syntax.js"
+37 -18
View File
@@ -4,8 +4,6 @@ import { expandTheme, mergeTheme } from "./expand.js"
import {
ActionState,
ActionVariant,
BaseHue,
HueAlias,
HueStep,
SurfaceName,
ThemeDefinition,
@@ -14,7 +12,6 @@ import {
import type {
ActionStateKey,
ActionStates,
HueDefinition,
HueScale,
Mode,
ResolvedActionState,
@@ -24,9 +21,12 @@ import type {
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index.js"
import { selectThemeMode } from "./select.js"
import { selectThemeMode, themeModes } from "./select.js"
type ResolvedHue = ResolvedThemeTokens["hue"] & Readonly<Record<string, HueScale>>
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition, { reportInput: true })
const decodeThemeDocumentSchema = Schema.decodeUnknownSync(ThemeDocument, { reportInput: true })
function decodeThemeDefinition(input: unknown) {
try {
@@ -42,6 +42,16 @@ export function themeDecodeError(error: unknown, name: string) {
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
}
export function parseThemeDocument(input: unknown, name = "theme") {
try {
const document = decodeThemeDocumentSchema(input)
themeModes(document).forEach((mode) => resolveThemeDocument(document, mode))
return document
} catch (error) {
throw themeDecodeError(error, name)
}
}
export function resolveThemeDocument(document: ThemeDocument, mode?: Mode) {
const selected = selectThemeMode(document, mode)
const definition = expandTheme(selected.theme)
@@ -54,7 +64,11 @@ export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
const hue = resolveHue(definition.hue)
const categorical = definition.categorical.map((name) => hue[name])
const categorical = definition.categorical.map((name) => {
const scale = hue[name]
if (!scale) throw new Error(`Categorical hue "${name}" was not found`)
return scale
})
const hueSteps = compileHueSteps(hue)
const base = tokens(definition)
const views = {} as Record<SurfaceName, ResolvedTheme>
@@ -156,10 +170,10 @@ function statefulColor(color: StatefulColor): StatefulColor {
}
function compileHueSteps(
hue: ResolvedThemeTokens["hue"],
hue: ResolvedHue,
): Pick<ResolvedThemeTokens, "source" | "increase" | "decrease"> {
const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) {
const index = new WeakMap<RGBA, { hue: string; step: HueStep; position: number }>()
for (const [name, scale] of Object.entries(hue)) {
HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
}
const shift = (color: RGBA, amount: number) => {
@@ -179,13 +193,9 @@ function compileHueSteps(
}
}
function resolveHue(definition: HueDefinition) {
function resolveHue(definition: ThemeDefinition["hue"]) {
const source = definition as Record<string, unknown>
const cache = new Map<string, HueScale>()
const expected = new Set<string>([...BaseHue.literals, ...HueAlias.literals])
for (const name of Object.keys(source)) {
if (!expected.has(name)) throw new Error(`Unknown hue "${name}"`)
}
function resolve(name: string, stack: string[]): HueScale {
const hit = cache.get(name)
@@ -193,8 +203,9 @@ function resolveHue(definition: HueDefinition) {
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
const value = source[name]
if (typeof value === "string") {
const match = /^\$hue\.([^.]+)$/.exec(value)
const match = /^\$hue\.(.+)$/.exec(value)
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
if (source[match[1]] === undefined) throw new Error(`Hue alias "${value}" references a missing hue`)
const target = resolve(match[1], [...stack, name])
const result = Object.fromEntries(HueStep.literals.map((step) => [step, RGBA.clone(target[step])])) as HueScale
cache.set(name, result)
@@ -216,9 +227,7 @@ function resolveHue(definition: HueDefinition) {
return result
}
return Object.fromEntries(
[...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]),
) as ResolvedThemeTokens["hue"]
return Object.fromEntries(Object.keys(source).map((name) => [name, resolve(name, [])])) as ResolvedHue
}
function createResolver(source: Record<string, unknown>) {
@@ -242,7 +251,7 @@ function createResolver(source: Record<string, unknown>) {
const hit = cache.get(target)
if (hit) return hit
if (stack.includes(target)) throw new Error(`Circular theme reference: ${[...stack, target].join(" -> ")}`)
const result = resolve(read(source, target), target, [...stack, target])
const result = resolve(readColor(source, target), target, [...stack, target])
if (!(result instanceof RGBA)) throw new Error(`Theme reference "${value}" at "${path}" is not a color`)
cache.set(target, result)
return result
@@ -251,6 +260,16 @@ function createResolver(source: Record<string, unknown>) {
return (value: unknown, path: string) => resolve(value, path)
}
function readColor(source: Record<string, unknown>, path: string) {
const match = /^hue\.(.+)\.(100|200|300|400|500|600|700|800|900)$/.exec(path)
if (!match?.[1] || !match[2]) return read(source, path)
const scales = source.hue
const scale = isRecord(scales) ? scales[match[1]] : undefined
const color = isRecord(scale) ? scale[match[2]] : undefined
if (color === undefined) throw new Error(`Theme reference "$${path}" was not found`)
return color
}
function resolvedKey(key: string) {
if (!key.startsWith("$")) return key
const state = key.slice(1)
+22 -24
View File
@@ -2,12 +2,7 @@ import { Schema } from "effect"
export const HueStep = Schema.Literals([100, 200, 300, 400, 500, 600, 700, 800, 900])
export type HueStep = Schema.Schema.Type<typeof HueStep>
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
export type BaseHue = Schema.Schema.Type<typeof BaseHue>
export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"])
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
export type SemanticHue = "accent" | "interactive" | "neutral"
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
@@ -37,29 +32,32 @@ const ColorValue = Schema.Union([
Schema.TemplateLiteral(["$", Schema.NonEmptyString]),
])
export const HueName = Schema.Union([BaseHue, HueAlias])
export const HueName = Schema.NonEmptyString
export type HueName = Schema.Schema.Type<typeof HueName>
export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLength(1))
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
const HueColorValue = Schema.Union([
HexColor,
Schema.String.check(Schema.isPattern(/^\$hue\..+\.(?:100|200|300|400|500|600|700|800|900)$/)),
])
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
const HueDefinition = Schema.Struct({
gray: HueValueDefinition,
red: HueValueDefinition,
orange: HueValueDefinition,
yellow: HueValueDefinition,
green: HueValueDefinition,
cyan: HueValueDefinition,
blue: HueValueDefinition,
purple: HueValueDefinition,
accent: HueValueDefinition,
interactive: HueValueDefinition,
neutral: HueValueDefinition,
})
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
const HueValueDefinition = Schema.Union([
Schema.String.check(Schema.isPattern(/^\$hue\..+$/)),
HueScaleDefinition,
])
type HueValueDefinition = Schema.Schema.Type<typeof HueValueDefinition>
const HueRecord = Schema.Record(HueName, HueValueDefinition)
const HueDefinition = HueRecord.check(
Schema.makeFilter<Schema.Schema.Type<typeof HueRecord>>((hues) => {
const missing = (["accent", "interactive", "neutral"] satisfies readonly SemanticHue[]).filter(
(name) => hues[name] === undefined,
)
return missing.length ? `Missing required semantic hues: ${missing.join(", ")}` : undefined
}),
)
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition> &
Readonly<Record<SemanticHue, HueValueDefinition>>
const StatefulColorDefinition = Schema.Struct({
base: Schema.optional(ColorValue),
+3 -4
View File
@@ -2,11 +2,10 @@ import type { RGBA } from "@opentui/core"
import type {
ActionState,
ActionVariant,
BaseHue,
FeedbackKind,
HueAlias,
HueStep,
MarkdownToken,
SemanticHue,
SurfaceName,
SyntaxToken,
} from "./schema.js"
@@ -14,8 +13,8 @@ import type {
export type ResolvedActionState = "base" | ActionState
export type ResolvedFormfieldState = ResolvedActionState
export type HueScale = Readonly<Record<HueStep, RGBA>>
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
export type Hue = Readonly<Record<SemanticHue, HueScale>>
export type HueSource = Readonly<{ hue: string; step: HueStep }>
export type Categorical = readonly HueScale[]
export type ActionStates = Readonly<Partial<Record<ActionState, boolean>>>
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>> & {
+12 -5
View File
@@ -1,6 +1,5 @@
import { RGBA } from "@opentui/core"
import { oklchToHex, rgbToOklch } from "./color.js"
import { DEFAULT_CATEGORICAL } from "./categorical.js"
import type { BaseThemeDefinition, HueDefinition, Mode, ThemeDefinition, ThemeDocument } from "./index.js"
import { HueStep } from "./schema.js"
import type { Theme, ThemeV1Json } from "./v1.js"
@@ -10,7 +9,15 @@ type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "p
type V1HueToken = "secondary" | "accent" | "success" | "warning" | "primary" | "error" | "info"
const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "success", "warning", "primary", "error"]
const categoricalTokens: readonly V1HueToken[] = [
"secondary",
"accent",
"success",
"warning",
"primary",
"error",
"info",
]
const minimumChroma = 0.03
const lightThreshold = 0.6
// Canonical swatches copied from the original default-theme classifier keep V1 migration self-contained.
@@ -98,16 +105,16 @@ function migrateMode(theme: Theme, mode: Mode): ThemeDefinition {
hue: {
gray: neutralScale(theme),
...Object.fromEntries(
chromaticHues.map((name) => {
chromaticHues.flatMap((name) => {
const match = hues.byHue[name]
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
return match ? [[name, hueScale(match.color, mode)]] : []
}),
),
accent: hues.byToken.accent ? `$hue.${hues.byToken.accent}` : "$hue.gray",
interactive: hues.byToken.primary ? `$hue.${hues.byToken.primary}` : "$hue.gray",
neutral: "$hue.gray",
} as HueDefinition,
categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
categorical: uniqueCategorical.length ? uniqueCategorical : ["neutral"],
text: {
base: text,
muted: textMuted,
@@ -24,6 +24,7 @@ import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
import { Link } from "../ui/link"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
import { formLabel, formToggleMultiselect, formValidateValue, type FormAnswerField } from "../util/form"
const INTEGRATION_PRIORITY: Record<string, number> = {
@@ -113,9 +114,7 @@ export function DialogIntegration(
category,
disabled: methods.length === 0 && credentials.length === 0,
gutter:
integration.connections.length > 0
? () => <text fg={theme.text.feedback.success.base}>✓</text>
: undefined,
integration.connections.length > 0 ? () => <text fg={theme.text.feedback.success.base}>✓</text> : undefined,
onSelect: () => {
if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected)
return selectMethod(integration, methods, location, dialog, props.onConnected)
@@ -195,9 +194,7 @@ function manageConnections(
fg: confirming ? theme.text.action.destructive.focused : undefined,
onSelect: () => {
if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return
void client.api.credential
.activate({ credentialID: connection.id })
.catch(toast.error)
void client.api.credential.activate({ credentialID: connection.id }).catch(toast.error)
},
}
}),
@@ -308,7 +305,13 @@ async function beginKey(
: undefined
if (answer === null) return
dialog.replace(() => (
<KeyMethod integration={integration} method={method} location={location} answer={answer} onConnected={onConnected} />
<KeyMethod
integration={integration}
method={method}
location={location}
answer={answer}
onConnected={onConnected}
/>
))
}
@@ -353,7 +356,7 @@ function CommandStarting(props: {
})
.catch((cause) => {
if (closed) return
toast.show({ variant: "error", message: message(cause) })
toast.show({ variant: "error", message: errorMessage(cause) })
dialog.clear()
})
})
@@ -406,7 +409,7 @@ function CommandPending(props: {
})
.catch((cause) => {
settled = true
toast.show({ variant: "error", message: message(cause) })
toast.show({ variant: "error", message: errorMessage(cause) })
dialog.clear()
})
}
@@ -484,7 +487,7 @@ function KeyMethod(props: {
...(props.answer ? { answer: props.answer } : {}),
})
.then(() => connected(props.integration, props.location, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause)))
.catch((cause) => setError(errorMessage(cause)))
}}
description={() => (
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.base}>{value()}</text>}</Show>
@@ -556,7 +559,7 @@ function OAuthStarting(props: {
))
})
.catch((cause) => {
toast.show({ variant: "error", message: message(cause) })
toast.show({ variant: "error", message: errorMessage(cause) })
dialog.clear()
})
})
@@ -633,7 +636,7 @@ function OAuthAuto(props: {
})
.catch((cause) => {
settled = true
toast.show({ variant: "error", message: message(cause) })
toast.show({ variant: "error", message: errorMessage(cause) })
dialog.clear()
})
}
@@ -702,7 +705,7 @@ function OAuthCode(props: {
settled = true
return connected(props.integration, props.location, data, dialog, toast, props.onConnected)
})
.catch((cause) => setError(message(cause)))
.catch((cause) => setError(errorMessage(cause)))
}}
description={() => (
<box gap={1}>
@@ -1031,8 +1034,3 @@ function providerID(data: ReturnType<typeof useData>, location: LocationRef, int
function locationQuery(location: LocationRef) {
return { directory: location.directory }
}
function message(cause: unknown) {
if (cause instanceof Error) return cause.message
return "Authentication failed"
}
@@ -3,6 +3,7 @@ import { useClient } from "../context/client"
import { useTheme } from "../context/theme"
import { SplitBorder } from "../ui/border"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
import { Spinner } from "./spinner"
type Progress = { label: string; numerator?: number; denominator?: number }
@@ -30,7 +31,7 @@ export function MigrationOverlay() {
toast.show({
variant: "error",
title: "Data migration failed",
message: error instanceof Error ? error.message : String(error),
message: errorMessage(error),
duration: 10_000,
})
})
@@ -7,6 +7,7 @@ import { Spinner } from "../../component/spinner"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { errorMessage } from "../../util/error"
const id = "opencode.plugins"
@@ -134,7 +135,7 @@ export function PluginsDialog(props: {
.catch((cause) => {
props.context.ui.toast.show({
variant: "error",
message: cause instanceof Error ? cause.message : String(cause),
message: errorMessage(cause),
})
})
.finally(() => setLocked(false))
@@ -152,7 +153,7 @@ export function PluginsDialog(props: {
.catch((cause) => {
props.context.ui.toast.show({
variant: "error",
message: cause instanceof Error ? cause.message : String(cause),
message: errorMessage(cause),
})
})
.finally(() => setPending((keys) => keys.filter((key) => key !== entry.key)))
@@ -173,7 +174,7 @@ export function PluginsDialog(props: {
.catch((cause) => {
props.context.ui.toast.show({
variant: "error",
message: cause instanceof Error ? cause.message : String(cause),
message: errorMessage(cause),
})
})
.finally(() => setChecking(false))
+3 -2
View File
@@ -12,6 +12,7 @@ import { SessionMessage } from "@opencode/schema/session-message"
import type { LocationRef } from "@opencode/client/promise"
import type { Config } from "../config"
import { newSessionLocation } from "../config/new-session-location"
import { errorMessage } from "../util/error"
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
import {
resolveMiniSettings,
@@ -863,7 +864,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
if (signal?.aborted || footer.isClosed) return
const text =
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
(error instanceof Error ? error.message : String(error))
errorMessage(error)
const commit = {
kind: "error",
text,
@@ -1007,7 +1008,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
})
const commit = {
kind: "error",
text: error instanceof Error ? error.message : String(error),
text: errorMessage(error),
phase: "start",
source: "system",
messageID: SessionMessage.ID.create(),
+29 -23
View File
@@ -26,15 +26,16 @@ import { PatchDiff } from "../../component/patch-diff"
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
SessionMessageInfo,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionMessageUser,
SessionInfo,
ModelInfo,
import {
isSessionNotFoundError,
type SessionMessageInfo,
type SessionMessageAssistant,
type SessionMessageAssistantReasoning,
type SessionMessageAssistantText,
type SessionMessageAssistantTool,
type SessionMessageUser,
type SessionInfo,
type ModelInfo,
} from "@opencode/client"
import { useLocal } from "../../context/local"
import { Locale } from "../../util/locale"
@@ -338,7 +339,9 @@ export function Session(props: {
variant: "error",
duration: 5000,
})
sessionTabs.enabled() ? sessionTabs.close(sessionID) : navigate({ type: "home" })
// Reconnects during a service restart can reach a server that is still starting or already stopping.
if (isSessionNotFoundError(error))
sessionTabs.enabled() ? sessionTabs.close(sessionID) : navigate({ type: "home" })
})
})
@@ -1102,8 +1105,9 @@ export function Session(props: {
try {
const sessionData = session()
if (!sessionData) return
const transcript = formatSessionTranscript(sessionData, messages(), true)
await clipboard.write(transcript)
const transcript = await client.api.session.export({ sessionID: sessionData.id })
const content = formatSessionTranscript(transcript.info, transcript.messages, true)
await clipboard.write(content)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch {
toast.show({ message: "Failed to copy session transcript", variant: "error" })
@@ -1127,14 +1131,14 @@ export function Session(props: {
if (options === null) return
const transcript = await client.api.session.export({
sessionID: sessionData.id,
sanitize: options.format === "json" ? options.sanitize : undefined,
})
const content =
options.format === "markdown"
? formatSessionTranscript(sessionData, messages(), options.thinking, options.tools)
: JSON.stringify(
await client.api.session.export({ sessionID: sessionData.id, sanitize: options.sanitize }),
null,
2,
) + EOL
? formatSessionTranscript(transcript.info, transcript.messages, options.thinking, options.tools)
: JSON.stringify(transcript, null, 2) + EOL
if (options.action === "copy") {
await clipboard.write(content)
@@ -1530,11 +1534,12 @@ function TurnTokenUsage(props: {
}))
const summary = createMemo(() => {
const items = steps()
const latest = items.at(-1)
return {
count: items.length,
newTokens: items.reduce((sum, item) => sum + item.newTokens, 0),
cached: items.reduce((sum, item) => sum + item.cached, 0),
total: items.reduce((sum, item) => sum + item.total, 0),
latestNewTokens: latest?.newTokens ?? 0,
latestCached: latest?.cached ?? 0,
latestTotal: latest?.total ?? 0,
reuseDrops: items.filter((item) => item.reuseDrop !== undefined).length,
}
})
@@ -1554,8 +1559,9 @@ function TurnTokenUsage(props: {
<span>{expanded() ? "- " : "+ "}</span>
<span style={{ attributes: TextAttributes.BOLD }}>Tokens</span>
<span>
: {summary().count} {summary().count === 1 ? "step" : "steps"} · {summary().newTokens.toLocaleString()}{" "}
new · {summary().cached.toLocaleString()} cached · {summary().total.toLocaleString()} total
: {summary().count} {summary().count === 1 ? "step" : "steps"} · latest:{" "}
{summary().latestNewTokens.toLocaleString()} new · {summary().latestCached.toLocaleString()} cached ·{" "}
{summary().latestTotal.toLocaleString()} total
</span>
<Show when={summary().reuseDrops > 0}>
<span style={{ fg: theme.text.feedback.warning.base }}>
+11 -15
View File
@@ -1,5 +1,10 @@
import { Schema } from "effect"
import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError, type ModeDefinition } from "@opencode/theme/tui"
import {
migrateV1,
parseThemeDocument,
resolveThemeDocument,
type ThemeDocument,
type ModeDefinition,
} from "@opencode/theme/tui"
import { resolveThemeColors } from "./resolve"
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
import opencode from "./assets/v2/opencode.json" with { type: "json" }
@@ -14,7 +19,6 @@ let customThemes: Record<string, ThemeDocumentSource> = {}
let systemTheme: ThemeDocumentSource | undefined
const listeners = new Set<(themes: Record<string, ThemeDocumentSource>) => void>()
const parsed = new WeakMap<object, ThemeDocument>()
const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument, { reportInput: true })
let opencodeTheme: (ThemeDocument & {
readonly light: ModeDefinition
readonly dark: ModeDefinition
@@ -22,9 +26,9 @@ let opencodeTheme: (ThemeDocument & {
export function getOpenCodeTheme() {
if (opencodeTheme) return opencodeTheme
const decoded = decodeThemeDocument(opencode) as NonNullable<typeof opencodeTheme>
opencodeTheme = decoded
return decoded
const document = parseThemeDocument(opencode, "opencode") as NonNullable<typeof opencodeTheme>
opencodeTheme = document
return document
}
function listThemes(): Record<string, ThemeDocumentSource> {
@@ -59,7 +63,7 @@ export function parseTheme(source: ThemeDocumentSource, name = "theme") {
const cached = parsed.get(source)
if (cached) return cached
const document = "theme" in source ? migrateV1(source as ThemeV1Json) : decodeV2Theme(source, name)
const document = "theme" in source ? migrateV1(source as ThemeV1Json) : parseThemeDocument(source, name)
parsed.set(source, document)
return document
@@ -116,11 +120,3 @@ export function resolveTheme(theme: ThemeV1Json, mode: "dark" | "light"): Theme
thinkingOpacity: resolved.thinkingOpacity,
}
}
function decodeV2Theme(source: ThemeDocumentSource, name: string) {
try {
return decodeThemeDocument(source)
} catch (error) {
throw themeDecodeError(error, name)
}
}
+3 -10
View File
@@ -4,6 +4,7 @@ import { useTheme } from "../context/theme"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "./border"
import { TextAttributes } from "@opentui/core"
import { errorMessage } from "../util/error"
export type ToastOptions = {
title?: string
message: string
@@ -164,16 +165,8 @@ function init() {
setStore("currentToast", toastOptions)
start(toastOptions.duration)
},
error: (err: any) => {
if (err instanceof Error)
return toast.show({
variant: "error",
message: err.message,
})
toast.show({
variant: "error",
message: "An unknown error has occurred",
})
error: (err: unknown) => {
toast.show({ variant: "error", message: errorMessage(err) })
},
pause() {
if (!store.currentToast || paused) return
+11 -11
View File
@@ -153,7 +153,7 @@ test("syncs VCS info and applies branch updates", async () => {
}
})
test("proactively syncs project metadata newest first", async () => {
test("proactively syncs project metadata most recently active first", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== "/api/project") return
@@ -162,14 +162,14 @@ test("proactively syncs project metadata newest first", async () => {
id: "proj_old",
canonical: "/old/project",
name: "Old project",
time: { created: 1, updated: 1 },
time: { created: 1, updated: 1, active: 3 },
sandboxes: [],
},
{
id: "proj_test",
canonical: worktree,
name: "OpenCode",
time: { created: 1, updated: 2 },
time: { created: 1, updated: 2, active: 2 },
sandboxes: [],
},
])
@@ -196,18 +196,18 @@ test("proactively syncs project metadata newest first", async () => {
try {
await wait(() => data.project.get("proj_test") !== undefined)
expect(data.project.list()).toEqual([
{
id: "proj_test",
canonical: worktree,
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_old",
canonical: "/old/project",
name: "Old project",
time: { created: 1, updated: 1 },
time: { created: 1, updated: 1, active: 3 },
sandboxes: [],
},
{
id: "proj_test",
canonical: worktree,
name: "OpenCode",
time: { created: 1, updated: 2, active: 2 },
sandboxes: [],
},
])
@@ -1,10 +1,11 @@
import { expect, test } from "bun:test"
import { resolveThemeDocument } from "@opencode/theme/tui"
import { resolveThemeDocument, type HueScale, type ResolvedTheme } from "@opencode/theme/tui"
import { terminalPalette } from "../../src/component/terminal-pane"
import { getOpenCodeTheme } from "../../src/theme"
test.each(["light", "dark"] as const)("builds the %s ANSI palette from semantic colors", (mode) => {
const theme = resolveThemeDocument(getOpenCodeTheme(), mode)
const hues = allHues(theme)
const black = theme.hue.neutral[mode === "dark" ? 800 : 200]
const white = theme.hue.neutral[mode === "dark" ? 200 : 800]
const brightWhite = theme.hue.neutral[mode === "dark" ? 100 : 900]
@@ -12,8 +13,8 @@ test.each(["light", "dark"] as const)("builds the %s ANSI palette from semantic
theme.text.feedback.error.base,
theme.text.feedback.success.base,
theme.text.feedback.warning.base,
theme.hue.blue[200],
mode === "dark" ? theme.hue.purple[200] : theme.hue.accent[200],
hues.blue[200],
mode === "dark" ? hues.purple[200] : theme.hue.accent[200],
theme.text.feedback.info.base,
white,
]
@@ -38,9 +39,10 @@ test.each(["light", "dark"] as const)("builds the %s ANSI palette from semantic
test("falls back to accent when no hue is confidently blue or magenta", () => {
const resolved = resolveThemeDocument(getOpenCodeTheme(), "dark")
const hues = allHues(resolved)
const hue = {
...Object.fromEntries(Object.keys(resolved.hue).map((name) => [name, resolved.hue.red])),
accent: resolved.hue.green,
...Object.fromEntries(Object.keys(hues).map((name) => [name, hues.red])),
accent: hues.green,
} as typeof resolved.hue
const theme = { ...resolved, hue }
const palette = [
@@ -60,3 +62,7 @@ function byte(value: number) {
function hex(color: { toInts(): [number, number, number, number] }) {
return `#${color.toInts().slice(0, 3).map(byte).join("")}`
}
function allHues(theme: ResolvedTheme) {
return theme.hue as typeof theme.hue & Readonly<Record<string, HueScale>>
}
+5 -5
View File
@@ -13,15 +13,15 @@ test("provides reactive properties, states, surfaces, and color operations", ()
expect(theme.text.base).toBe(resolved().text.base)
expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500])
expect(theme.hue.interactive[500]).toBe(resolved().hue.interactive[500])
expect(theme.hue.gray[200]).toBe(resolved().hue.gray[200])
expect(theme.hue.neutral[200]).toBe(resolved().hue.neutral[200])
expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
expect(theme.increase(theme.background.raised.base, 1)).toBe(resolved().hue.neutral[800])
expect(theme.decrease(theme.background.raised.base)).toBe(resolved().hue.neutral[600])
expect(theme.decrease(theme.hue.red[300], 2)).toBe(resolved().hue.red[100])
expect(theme.increase(theme.hue.red[900], 3)).toBe(resolved().hue.red[900])
expect(theme.decrease(theme.hue.red[100], 3)).toBe(resolved().hue.red[100])
expect(theme.decrease(theme.hue.accent[300], 2)).toBe(resolved().hue.accent[100])
expect(theme.increase(theme.hue.accent[900], 3)).toBe(resolved().hue.accent[900])
expect(theme.decrease(theme.hue.accent[100], 3)).toBe(resolved().hue.accent[100])
expect(theme.source(theme.background.raised.base)).toEqual({ hue: "neutral", step: 700 })
const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
const equivalent = RGBA.fromInts(...resolved().categorical[0][500].toInts())
expect(theme.source(equivalent)).toBeUndefined()
expect(theme.increase(equivalent, 1)).toBe(equivalent)
const unmatched = RGBA.fromInts(1, 2, 3)
+74 -31
View File
@@ -1,37 +1,41 @@
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import {
BaseHue,
generateSyntax,
resolveTheme,
resolveThemeDocument,
selectTheme,
type HueScale,
type Mode,
type ResolvedTheme,
type ThemeDefinition,
} from "@opencode/theme/tui"
import { getOpenCodeTheme, parseTheme, type ThemeDocumentSource } from "../../../src/theme"
const opencodeLight = selectTheme(getOpenCodeTheme(), "light")
const opencodeDark = selectTheme(getOpenCodeTheme(), "dark")
const light = {
const chromaticHues = ["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"] as const
const light: ThemeDefinition = {
...opencodeLight,
categorical: ["blue", "purple"],
hue: { ...opencodeLight.hue, accent: "$hue.blue", interactive: "$hue.blue", neutral: "$hue.gray" },
} satisfies ThemeDefinition
const dark = {
}
const dark: ThemeDefinition = {
...opencodeDark,
categorical: ["blue", "purple"],
hue: { ...opencodeDark.hue, accent: "$hue.blue", interactive: "$hue.blue", neutral: "$hue.gray" },
} satisfies ThemeDefinition
}
test("orders light hues dark-to-light and dark hues light-to-dark", () => {
const lightTheme = resolveTheme(light)
const darkTheme = resolveTheme(dark)
const lightHues = allHues(lightTheme)
const darkHues = allHues(darkTheme)
const luminance = (color: RGBA) => 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
BaseHue.literals.forEach((name) => {
expect(luminance(lightTheme.hue[name][100])).toBeLessThan(luminance(lightTheme.hue[name][900]))
expect(luminance(darkTheme.hue[name][100])).toBeGreaterThan(luminance(darkTheme.hue[name][900]))
chromaticHues.forEach((name) => {
expect(luminance(lightHues[name][100])).toBeLessThan(luminance(lightHues[name][900]))
expect(luminance(darkHues[name][100])).toBeGreaterThan(luminance(darkHues[name][900]))
})
})
@@ -61,9 +65,10 @@ test("rejects theme documents without a mode", () => {
test("validates and resolves categorical hues in configured order", () => {
const theme = resolveSource(complete("light", { categorical: ["accent", "red", "interactive"] }), "light")
const hues = allHues(theme)
expect(theme.categorical[0]).toBe(theme.hue.accent)
expect(theme.categorical[1]).toBe(theme.hue.red)
expect(theme.categorical[1]).toBe(hues.red)
expect(theme.categorical[2]).toBe(theme.hue.interactive)
expect(theme.surface("dialog").categorical).toBe(theme.categorical)
expect(() => resolveSource(complete("light", { categorical: [] }), "light")).toThrow("Invalid theme")
@@ -72,6 +77,36 @@ test("validates and resolves categorical hues in configured order", () => {
).toThrow("Invalid theme")
})
test("resolves arbitrary hue names across aliases, categorical colors, and token references", () => {
const ocean = light.hue.blue
if (typeof ocean !== "object") throw new Error("Expected a concrete blue scale")
const theme = resolveTheme({
...light,
hue: { ...light.hue, "brand.ocean": ocean, accent: "$hue.brand.ocean" },
categorical: ["brand.ocean"],
syntax: { ...light.syntax, keyword: "$hue.brand.ocean.200" },
})
const hues = allHues(theme)
expect(theme.hue.accent[200].equals(hues["brand.ocean"][200])).toBeTrue()
expect(theme.categorical[0]).toBe(hues["brand.ocean"])
expect(theme.syntax.keyword).toBe(hues["brand.ocean"][200])
expect(theme.source(theme.syntax.keyword)).toEqual({ hue: "brand.ocean", step: 200 })
})
test("validates hue relationships in every provided mode while parsing", () => {
const source = structuredClone(getOpenCodeTheme()) as ThemeDocumentSource
const darkMode = source.dark as Record<string, unknown>
darkMode.hue = { ...(darkMode.hue as Record<string, unknown>), accent: "$hue.missing" }
const missingSemantic = structuredClone(getOpenCodeTheme()) as ThemeDocumentSource
const lightMode = missingSemantic.light as Record<string, unknown>
const lightHues = lightMode.hue as Record<string, unknown>
delete lightHues.neutral
expect(() => parseTheme(source, "broken-dark")).toThrow("Invalid theme: broken-dark")
expect(() => parseTheme(missingSemantic, "missing-semantic")).toThrow("Invalid theme: missing-semantic")
})
test("generates syntax with one categorical hue", () => {
const theme = resolveSource(complete("light", { categorical: ["red"] }), "light")
const syntax = generateSyntax(theme)
@@ -90,22 +125,24 @@ test("rejects incomplete themes instead of merging defaults", () => {
test("resolves independent definitions and hue aliases", () => {
const lightTheme = resolveTheme(light)
const darkTheme = resolveTheme(dark)
const lightHues = allHues(lightTheme)
const darkHues = allHues(darkTheme)
expect(lightTheme.hue.accent).not.toBe(lightTheme.hue.blue)
expect(lightTheme.hue.accent[500].equals(lightTheme.hue.blue[500])).toBeTrue()
expect(lightTheme.hue.interactive).not.toBe(lightTheme.hue.blue)
expect(lightTheme.hue.interactive[500].equals(lightTheme.hue.blue[500])).toBeTrue()
expect(lightTheme.hue.neutral).not.toBe(lightTheme.hue.gray)
expect(lightTheme.hue.neutral[500].equals(lightTheme.hue.gray[500])).toBeTrue()
expect(lightTheme.categorical[0]).toBe(lightTheme.hue.blue)
expect(lightTheme.source(lightTheme.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
expect(lightTheme.hue.accent).not.toBe(lightHues.blue)
expect(lightTheme.hue.accent[500].equals(lightHues.blue[500])).toBeTrue()
expect(lightTheme.hue.interactive).not.toBe(lightHues.blue)
expect(lightTheme.hue.interactive[500].equals(lightHues.blue[500])).toBeTrue()
expect(lightTheme.hue.neutral).not.toBe(lightHues.gray)
expect(lightTheme.hue.neutral[500].equals(lightHues.gray[500])).toBeTrue()
expect(lightTheme.categorical[0]).toBe(lightHues.blue)
expect(lightTheme.source(lightHues.blue[500])).toEqual({ hue: "blue", step: 500 })
expect(lightTheme.source(lightTheme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
expect(lightTheme.source(lightTheme.background.raised.base)).toEqual({ hue: "neutral", step: 700 })
expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
expect(lightTheme.surface("dialog").increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
expect(darkTheme.decrease(darkTheme.hue.red[200])).toBe(darkTheme.hue.red[100])
expect(lightTheme.increase(lightHues.red[100])).toBe(lightHues.red[200])
expect(lightTheme.decrease(lightHues.red[200])).toBe(lightHues.red[100])
expect(lightTheme.surface("dialog").increase(lightHues.red[100])).toBe(lightHues.red[200])
expect(lightTheme.decrease(lightHues.red[200])).toBe(lightHues.red[100])
expect(darkTheme.decrease(darkHues.red[200])).toBe(darkHues.red[100])
expect(lightTheme.text.base).toBeInstanceOf(RGBA)
expect(darkTheme.background.base).toBeInstanceOf(RGBA)
expect(lightTheme.background.raised.base).toBe(lightTheme.hue.neutral[700])
@@ -136,16 +173,18 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
complete("light", { hue: { ...light.hue, blue: "$hue.red" } }),
"light",
)
const aliasedHues = allHues(aliased)
const overriddenHues = allHues(overridden)
expect(aliased.hue.blue).not.toBe(aliased.hue.red)
expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue()
expect(aliased.hue.purple).not.toBe(aliased.hue.blue)
expect(aliased.hue.purple[500].equals(aliased.hue.red[500])).toBeTrue()
expect(overridden.hue.blue).not.toBe(overridden.hue.red)
expect(overridden.hue.blue[500].equals(overridden.hue.red[500])).toBeTrue()
expect(aliased.source(aliased.hue.red[500])).toEqual({ hue: "red", step: 500 })
expect(aliased.source(aliased.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
expect(aliased.source(aliased.hue.purple[500])).toEqual({ hue: "purple", step: 500 })
expect(aliasedHues.blue).not.toBe(aliasedHues.red)
expect(aliasedHues.blue[500].equals(aliasedHues.red[500])).toBeTrue()
expect(aliasedHues.purple).not.toBe(aliasedHues.blue)
expect(aliasedHues.purple[500].equals(aliasedHues.red[500])).toBeTrue()
expect(overriddenHues.blue).not.toBe(overriddenHues.red)
expect(overriddenHues.blue[500].equals(overriddenHues.red[500])).toBeTrue()
expect(aliased.source(aliasedHues.red[500])).toEqual({ hue: "red", step: 500 })
expect(aliased.source(aliasedHues.blue[500])).toEqual({ hue: "blue", step: 500 })
expect(aliased.source(aliasedHues.purple[500])).toEqual({ hue: "purple", step: 500 })
expect(() =>
resolveTheme(
{
@@ -320,6 +359,10 @@ function override(base: ThemeDefinition, value: unknown) {
return merge(base, value) as ThemeDefinition
}
function allHues(theme: ResolvedTheme) {
return theme.hue as typeof theme.hue & Readonly<Record<string, HueScale>>
}
function merge(...values: unknown[]): Record<string, unknown> {
return values.reduce<Record<string, unknown>>((result, value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return result
+27 -19
View File
@@ -1,10 +1,11 @@
import { expect, test } from "bun:test"
import {
DEFAULT_CATEGORICAL,
migrateV1,
resolveThemeDocument,
selectThemeMode,
themeModes,
type HueScale,
type ResolvedTheme,
} from "@opencode/theme/tui"
import { DEFAULT_THEMES, getOpenCodeTheme, resolveTheme as resolveV1 } from "../../../src/theme"
import opencodeSource from "../../../src/theme/assets/opencode.json" with { type: "json" }
@@ -13,6 +14,7 @@ import type { ThemeV1Json } from "@opencode/theme/tui/v1"
const opencodeV1 = opencodeSource as ThemeV1Json
const opencodeLight = resolveThemeDocument(getOpenCodeTheme(), "light")
const opencodeDark = resolveThemeDocument(getOpenCodeTheme(), "dark")
const opencodeLightHues = allHues(opencodeLight)
test("migrates resolved V1 modes into V2 tokens", () => {
const migrated = migrateV1(opencodeV1)
@@ -72,7 +74,7 @@ test("references generated hues from matching token colors", () => {
expect(migrated.base.markdown?.emphasis).toBe("#123456")
})
test("infers chromatic hues, anchors light and dark colors, and aliases ambiguous hues to gray", () => {
test("infers and emits only chromatic hues represented by V1 colors", () => {
const source = structuredClone(opencodeV1)
const ambiguous = { light: "#808080", dark: "#808080" }
source.theme.accent = ambiguous
@@ -93,12 +95,12 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
expect(darkRed[200]).toBe("#450000")
expect(lightRed[100]).not.toBe(lightRed[200])
expect(darkRed[100]).not.toBe(darkRed[200])
expect(migrated.light.hue?.orange).toBe("$hue.gray")
expect(migrated.light.hue?.yellow).toBe("$hue.gray")
expect(migrated.light.hue?.green).toBe("$hue.gray")
expect(migrated.light.hue?.cyan).toBe("$hue.gray")
expect(migrated.light.hue?.blue).toBe("$hue.gray")
expect(migrated.light.hue?.purple).toBe("$hue.gray")
expect(migrated.light.hue?.orange).toBeUndefined()
expect(migrated.light.hue?.yellow).toBeUndefined()
expect(migrated.light.hue?.green).toBeUndefined()
expect(migrated.light.hue?.cyan).toBeUndefined()
expect(migrated.light.hue?.blue).toBeUndefined()
expect(migrated.light.hue?.purple).toBeUndefined()
expect(migrated.light.hue?.accent).toBe("$hue.gray")
expect(migrated.light.hue?.interactive).toBe("$hue.gray")
expect(() => resolveThemeDocument(migrated, "light")).not.toThrow()
@@ -135,21 +137,22 @@ test("orders categorical hues by V1 semantic color mapping", () => {
source.theme.warning = mapped("yellow")
source.theme.primary = mapped("blue")
source.theme.error = mapped("red")
source.theme.info = { light: "#67e8f9", dark: "#0e7490" }
const migrated = migrateV1(source)
expect(migrated.base.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
expect(migrated.dark?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
expect(migrated.base.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red", "cyan"])
expect(migrated.dark?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red", "cyan"])
source.theme.accent = source.theme.secondary
expect(migrateV1(source).base.categorical).toEqual(["purple", "green", "yellow", "blue", "red"])
expect(migrateV1(source).base.categorical).toEqual(["purple", "green", "yellow", "blue", "red", "cyan"])
})
test("gives accent and primary ownership of their inferred hues", () => {
const source = structuredClone(opencodeV1)
source.theme.success = hex(opencodeLight.hue.orange[700])
source.theme.accent = hex(opencodeLight.hue.orange[600])
source.theme.info = hex(opencodeLight.hue.blue[700])
source.theme.primary = hex(opencodeLight.hue.blue[600])
source.theme.success = hex(opencodeLightHues.orange[700])
source.theme.accent = hex(opencodeLightHues.orange[600])
source.theme.info = hex(opencodeLightHues.blue[700])
source.theme.primary = hex(opencodeLightHues.blue[600])
const migrated = migrateV1(source)
if (!migrated.light) throw new Error("Expected light mode")
@@ -162,7 +165,7 @@ test("gives accent and primary ownership of their inferred hues", () => {
expect(migrated.light.hue?.accent).toBe("$hue.orange")
expect(migrated.light.hue?.interactive).toBe("$hue.blue")
source.theme.primary = hex(opencodeLight.hue.orange[500])
source.theme.primary = hex(opencodeLightHues.orange[500])
const collisionMode = migrateV1(source).light
const collision = collisionMode?.hue?.orange
if (typeof collision !== "object") throw new Error("Expected concrete orange scale")
@@ -171,7 +174,7 @@ test("gives accent and primary ownership of their inferred hues", () => {
expect(collisionMode?.hue?.interactive).toBe("$hue.orange")
})
test("uses default categorical hues when V1 semantic colors are ambiguous", () => {
test("uses the semantic neutral hue when V1 categorical colors are ambiguous", () => {
const source = structuredClone(opencodeV1)
source.theme.secondary = "transparent"
source.theme.accent = "transparent"
@@ -179,10 +182,11 @@ test("uses default categorical hues when V1 semantic colors are ambiguous", () =
source.theme.warning = "transparent"
source.theme.primary = "transparent"
source.theme.error = "transparent"
source.theme.info = "transparent"
const migrated = migrateV1(source)
expect(migrated.base.categorical).toEqual(DEFAULT_CATEGORICAL)
expect(migrated.dark?.categorical).toEqual(DEFAULT_CATEGORICAL)
expect(migrated.base.categorical).toEqual(["neutral"])
expect(migrated.dark?.categorical).toEqual(["neutral"])
})
test("builds and extrapolates gray from V1 surfaces and text without using menus or borders", () => {
@@ -282,6 +286,10 @@ test("keeps both modes when a shared background has different contrast", () => {
expect(themeModes(migrated)).toEqual(["light", "dark"])
})
function allHues(theme: ResolvedTheme) {
return theme.hue as typeof theme.hue & Readonly<Record<string, HueScale>>
}
function hex(color: { toInts(): [number, number, number, number] }) {
const [r, g, b, a] = color.toInts()
const byte = (value: number) => value.toString(16).padStart(2, "0")
-18
View File
@@ -1,18 +0,0 @@
diff --git a/lib/git.js b/lib/git.js
index 000ee9fc..a2a6cbb7 100644
--- a/lib/git.js
+++ b/lib/git.js
@@ -254,8 +254,11 @@ class GitFetcher extends Fetcher {
resolved: this.resolved,
integrity: null, // it'll always be different, if we have one
}).extract(tmp).then(() => handler(`${tmp}${this.spec.gitSubdir || ''}`), er => {
- // fall back to ssh download if tarball fails
- if (er.constructor.name.match(/^Http/)) {
+ // fall back to clone if the tarball download fails due to an
+ // HTTP error or if the response is not a valid tarball (e.g.
+ // a hosted provider returning an HTML sign-in page with 200)
+ if ((typeof er.statusCode === 'number' && er.statusCode >= 400) ||
+ /^TAR_/.test(er.code)) {
return this.#clone(handler, false)
} else {
throw er
+58
View File
@@ -0,0 +1,58 @@
diff --git a/node_modules/pacote/.bun-tag-eb0e1d32c8fdffa0 b/.bun-tag-eb0e1d32c8fdffa0
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/lib/git.js b/lib/git.js
index 8faf125c6e5e572e980cfc8aaf4e6c9a21d740cf..b1ae3c8cdfa9cbdbd7573be7fa74197d158e6bcb 100644
--- a/lib/git.js
+++ b/lib/git.js
@@ -247,15 +247,20 @@ class GitFetcher extends Fetcher {
// if we're resolved, and have a tarball url, shell out to RemoteFetcher
if (tarballOk) {
const nameat = this.spec.name ? `${this.spec.name}@` : ''
- return new RemoteFetcher(h.tarball({ noCommittish: false }), {
+ // the hosted committish also carries ::path:, which is not a valid
+ // archive ref; download the commit and select the subdirectory below
+ return new RemoteFetcher(h.tarball({ committish: this.resolvedSha }), {
...this.opts,
allowGitIgnore: true,
pkgid: `git:${nameat}${this.resolved}`,
resolved: this.resolved,
integrity: null, // it'll always be different, if we have one
}).extract(tmp).then(() => handler(`${tmp}${this.spec.gitSubdir || ''}`), er => {
- // fall back to ssh download if tarball fails
- if (er.constructor.name.match(/^Http/)) {
+ // fall back to clone if the tarball download fails due to an
+ // HTTP error or if the response is not a valid tarball (e.g.
+ // a hosted provider returning an HTML sign-in page with 200)
+ if ((typeof er.statusCode === 'number' && er.statusCode >= 400) ||
+ /^TAR_/.test(er.code)) {
return this.#clone(handler, false)
} else {
throw er
diff --git a/lib/util/add-git-sha.js b/lib/util/add-git-sha.js
index 8518bff9e202a61cd4451d184b74015b43f24ad0..f46620d77c8ee336c20c1c93916307ec3be6e183 100644
--- a/lib/util/add-git-sha.js
+++ b/lib/util/add-git-sha.js
@@ -1,17 +1,20 @@
// add a sha to a git remote url spec
const addGitSha = (spec, sha) => {
+ // the sha replaces any committish or semver range, but the subdirectory
+ // still selects the package within the resolved commit
+ const subdir = spec.gitSubdir ? `::path:${spec.gitSubdir.slice(1)}` : ''
if (spec.hosted) {
const h = spec.hosted
const opt = { noCommittish: true }
const base = h.https && h.auth ? h.https(opt) : h.shortcut(opt)
- return `${base}#${sha}`
+ return `${base}#${sha}${subdir}`
} else {
// don't use new URL for this, because it doesn't handle scp urls
// strip the committish with indexOf/slice to avoid a regexp redos
const hashIndex = spec.rawSpec.indexOf('#')
const base = hashIndex === -1 ? spec.rawSpec : spec.rawSpec.slice(0, hashIndex)
- return `${base}#${sha}`
+ return `${base}#${sha}${subdir}`
}
}
+15 -1
View File
@@ -1556,6 +1556,16 @@
}
]
},
"metadata": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Metadata"
},
{
"type": "null"
}
]
},
"permissions": {
"anyOf": [
{
@@ -16308,9 +16318,13 @@
"updated": {
"type": "integer",
"minimum": 0
},
"active": {
"type": "integer",
"minimum": 0
}
},
"required": ["created", "updated"],
"required": ["created", "updated", "active"],
"additionalProperties": false
},
"Project.Vcs": {
+15 -1
View File
@@ -1556,6 +1556,16 @@
}
]
},
"metadata": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Metadata"
},
{
"type": "null"
}
]
},
"permissions": {
"anyOf": [
{
@@ -16308,9 +16318,13 @@
"updated": {
"type": "integer",
"minimum": 0
},
"active": {
"type": "integer",
"minimum": 0
}
},
"required": ["created", "updated"],
"required": ["created", "updated", "active"],
"additionalProperties": false
},
"Project.Vcs": {