Compare commits

..
Author SHA1 Message Date
Aiden Cline 0d238165a2 feat(codemode): add Object.freeze/seal/create/getPrototypeOf and structuredClone 2026-09-23 00:00:55 -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]andrekram1-node 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]andrekram1-node 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]andjlongster 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
100 changed files with 2151 additions and 422 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 = {
+63 -19
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
@@ -98,6 +102,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references. `null`, `undefined`, and other
non-objects iterate nothing. An un-awaited promise throws rather than iterating.
- [ ] `for...in` over inherited enumerable keys (`Object.create(proto)`), and skipping keys deleted during the loop.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
@@ -150,6 +155,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Redeclaring a function in the same scope, or alongside a `var`, is allowed: the last declaration wins.
- [x] Generator functions have their own `prototype` (inheriting the shared generator prototype), so
`g() instanceof g` holds. Plain functions have none, since they cannot construct.
- [ ] `GeneratorFunction.prototype`: every function, generator or not, inherits directly from `Function.prototype`,
and a generator whose `prototype` was replaced by a non-object still creates from the shared generator prototype.
- [x] Generator and async generator functions bind parameters (defaults, destructuring) at the call and defer only the
body to the first `next()`, so a bad argument throws synchronously from the call site, as in JS.
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
@@ -201,10 +208,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.
@@ -278,9 +293,27 @@ reject }` object.
- [x] Every value has a real prototype chain built fresh for each run: `Object.prototype`, `Array.prototype`,
`String.prototype`, `Error.prototype` → `TypeError.prototype`, and so on hold the built-in methods as
non-enumerable properties, and each constructor's `prototype` points at it (`[].constructor === Array`,
`Object.getPrototypeOf` is not exposed). Programs may read and even overwrite these prototypes; the change is
confined to that run. `__proto__` is an ordinary own data key, so `o.__proto__ = x` never changes the chain, and
`Object.groupBy` results have no prototype at all, as in JS.
`Object.getPrototypeOf([]) === Array.prototype`). Programs may read and even overwrite these prototypes; the
change is confined to that run. `__proto__` is an ordinary own data key, so `o.__proto__ = x` never changes the
chain, and `Object.groupBy` results have no prototype at all, as in JS.
- [x] `Object.getPrototypeOf`: the prototype of an object, or the built-in prototype for a string, number, or boolean
(`Object.getPrototypeOf("a") === String.prototype`); `null`, `undefined`, and the two confined symbols throw a
`TypeError`.
- [x] `Object.create(proto)` with an object or `null` prototype; any other prototype is a `TypeError`
(`Object prototype may only be an Object or null`). Inherited reads, `in`, `hasOwnProperty`, and own-only
`Object.keys` follow the chain as in JS, but `for...in` still enumerates own keys only. A second `properties`
argument other than `undefined` throws a `TypeError`: property descriptors are not supported (there is no
`Object.defineProperty` either).
- [x] `Object.freeze`, `Object.seal`, and `Object.preventExtensions`, with `isFrozen`, `isSealed`, and `isExtensible`.
Each returns its argument and passes primitives through (`Object.freeze(1) === 1`, `Object.isFrozen(1)`).
Programs run as strict code, so violations throw `TypeError`: `Cannot assign to read only property 'a'`,
`Cannot add property b, object is not extensible`, and `Cannot delete property 'a'`. Arrays enforce the same
rules for index writes, `length`, and the mutating methods (`Object.freeze([1]).push(2)` throws; a sealed array's
`pop()` throws; a non-extensible array's `pop()`, `sort()`, and `splice(0, 1, x)` work). The mutating methods are
checked once up front, so a `fill` or `sort` that would land on a hole of a non-extensible array succeeds here
where JS throws. Maps, Sets, and Dates freeze their properties only, not their contents, as in JS.
`Object.freeze(new Uint8Array(1))` and `seal` throw `TypeError` like JS (`preventExtensions` and empty typed
arrays are fine). Functions are objects and freeze like any other value.
- [x] Circular references are rejected when created (`o.self = o`, `array.push(array)`), not at serialization as in JS.
- [x] `Object.is` for supported data values.
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
@@ -307,8 +340,8 @@ reject }` object.
- [x] `keys`, `values`, `entries`, and `[Symbol.iterator]` (the same function as `values`) return live iterator objects
with `next()` and `[Symbol.iterator]`, as in JS. Iterator objects are opaque references: they print as
`[opaque reference]`, serialize to `{}`, and cannot be passed to extensions. Every built-in collection iterator
shares one prototype. JavaScript gives each collection its own; the difference is not observable here because
`Object.getPrototypeOf` is not exposed.
shares one prototype, where JavaScript gives each collection its own; `Object.getPrototypeOf` shows the
difference.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [x] `Array.prototype.toSpliced`.
@@ -343,10 +376,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 +441,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.
@@ -492,6 +528,12 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
- [x] `atob` and `btoa` with forgiving-base64 decoding and WebIDL string conversion; invalid input throws a
`TypeError`, since there is no `DOMException`.
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
- [x] `structuredClone(value)` deep-copies primitives, plain objects (own enumerable string keys onto a plain object;
the prototype is not kept), arrays (holes and extra keys kept), Map, Set, Date, RegExp (`lastIndex` reset to 0),
Uint8Array, and errors (standard `name`, `message`, `cause`, and `stack`; other names become `Error`, extra
fields drop). Shared references stay shared in the copy, and the copy is extensible even when the source was
frozen. Functions, symbols, promises, iterators, URL, Headers, and tool references cannot be cloned; without a
`DOMException`, the failure is a `TypeError` whose message starts with `DataCloneError:`.
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
@@ -543,6 +585,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
non-enumerable: an extension Error carries it, and this JSON form does not. Errors have no `stack`; the diagnostic
carries a 1-based line and column in the submitted source instead.
- [x] `instanceof` against any constructor with a `prototype`, including every built-in and `Function`.
- [ ] The derived error constructors inheriting from `Error`: `Object.getPrototypeOf(TypeError)` is
`Function.prototype` here, while `TypeError.prototype` does inherit from `Error.prototype`.
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
`catch`.
+2 -1
View File
@@ -15,7 +15,7 @@ import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
import { headersGlobal } from "../stdlib/headers.js"
import { iteratorGlobals } from "../stdlib/iterator.js"
import { coercion } from "../stdlib/value.js"
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
import { base64Global, cryptoGlobal, structuredCloneGlobal } from "../stdlib/web.js"
import { ToolReference } from "../tool-runtime.js"
import { errorGlobal } from "./errors.js"
import { errorTypes } from "./intrinsics.js"
@@ -95,6 +95,7 @@ const table: Record<string, Factory> = {
atob: (ctx) => base64Global(ctx, "atob"),
btoa: (ctx) => base64Global(ctx, "btoa"),
crypto: (ctx) => cryptoGlobal(ctx),
structuredClone: (ctx) => structuredCloneGlobal(ctx),
...Object.fromEntries(errorTypes.map((type) => [type, <R>(ctx: Interpreter<R>) => errorGlobal(type, ctx)])),
}
@@ -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),
@@ -2167,7 +2213,6 @@ class Frame<R> {
}
})()
if (written) return
if (target instanceof Arr && key === "length") throw rangeError("Invalid array length", node)
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
}
+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(
+30 -6
View File
@@ -9,6 +9,8 @@ import {
type GeneratorRequestKind,
IteratorSymbol,
type PendingThrow,
rangeError,
typeError,
} from "./model.js"
/** Property attributes, as in a JS property descriptor. */
@@ -41,6 +43,8 @@ export const frozen: Attributes = { writable: false, enumerable: false, configur
*/
export class Obj {
readonly props = new Map<string | symbol, Slot>()
/** [[Extensible]]: cleared by `Object.preventExtensions`, `seal`, and `freeze`. */
extensible = true
constructor(public proto: Obj | null) {}
/** The class name `Object.prototype.toString` reports: `[object Map]`. */
@@ -91,6 +95,8 @@ export class Obj {
export class Arr extends Obj {
override readonly tag = "Array"
/** The attributes every live element shares; `seal` and `freeze` narrow them since elements have no slots. */
elements: Attributes = data
constructor(
proto: Obj,
readonly items: Array<Value> = [],
@@ -460,10 +466,11 @@ export const own = (target: Obj, key: PropertyKey): Slot | undefined => {
const at = index(target, name)
if (at !== undefined) {
const items = elements(target)
return at in items ? { value: items[at], ...data } : undefined
if (!(at in items)) return undefined
return { value: items[at], ...(target instanceof Arr ? target.elements : data) }
}
if (target instanceof Arr && name === "length") {
return { value: target.items.length, writable: true, enumerable: false, configurable: false }
return { value: target.items.length, writable: target.elements.writable, enumerable: false, configurable: false }
}
}
return target.props.get(name)
@@ -505,13 +512,19 @@ export const hasPrototype = (value: Value, proto: Obj): boolean => {
const writeElement = (target: Indexed, name: string | symbol, value: Value): boolean | undefined => {
const at = index(target, name)
if (at !== undefined) {
if (target instanceof Bytes) target.bytes[at] = typeof value === "number" ? value : Number(value)
else target.items[at] = value
if (target instanceof Bytes) {
target.bytes[at] = typeof value === "number" ? value : Number(value)
return true
}
if (!(at in target.items)) rejectAddition(target, at)
target.items[at] = value
return true
}
if (!(target instanceof Arr) || name !== "length") return undefined
const length = typeof value === "number" ? value : Number(value)
if (!Number.isInteger(length) || length < 0) return false
if (!Number.isInteger(length) || length < 0) throw rangeError("Invalid array length")
// Shrinking deletes elements, which a sealed array forbids.
if (length < target.items.length && !target.elements.configurable) return false
checkArrayLength(length)
target.items.length = length
return true
@@ -541,10 +554,17 @@ export const set = (target: Obj, key: PropertyKey, value: Value): boolean => {
const written = writeElement(target, name, value)
if (written !== undefined) return written
}
rejectAddition(target, name)
target.props.set(name, { value, ...data })
return true
}
/** Creating a property on a non-extensible object is the one [[Set]] failure with its own message. */
export const rejectAddition = (target: Obj, key: string | symbol | number): void => {
if (target.extensible) return
throw typeError(`Cannot add property ${String(key)}, object is not extensible.`)
}
/** [[DefineOwnProperty]] for a data property, ignoring the chain. */
export const define = (target: Obj, key: PropertyKey, value: Value, attrs: Attributes = data): void => {
const name = canonical(key)
@@ -560,7 +580,11 @@ export const remove = (target: Obj, key: PropertyKey): boolean => {
const name = canonical(key)
if (isIndexed(target)) {
const at = index(target, name)
if (at !== undefined) return target instanceof Bytes ? !(at in target.bytes) : delete target.items[at]
if (at !== undefined) {
if (target instanceof Bytes) return !(at in target.bytes)
if (at in target.items && !target.elements.configurable) return false
return delete target.items[at]
}
if (target instanceof Arr && name === "length") return false
}
const slot = target.props.get(name)
+30 -11
View File
@@ -14,6 +14,7 @@ import {
coerceToInteger,
coerceToNumber,
coerceToString,
rejectAddition,
type Value,
} from "../interpreter/objects.js"
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
@@ -124,6 +125,20 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
])
const self = (thisValue: Value, name: string) => receiver(Arr, thisValue, `Array.prototype.${name}`)
// A mutating method fails where its first element write, delete, or `length` write would on a frozen, sealed, or
// non-extensible array. `growth` is given by the methods that always write `length`, even when it is 0; holes a
// method would fill on a non-extensible array are not checked.
const mutable = (target: Arr, growth?: number): Arr => {
const length = target.items.length
if ((growth ?? 0) > 0) rejectAddition(target, length)
if ((growth ?? 0) < 0 && length > 0 && !target.elements.configurable) {
throw typeError(`Cannot delete property '${length - 1}'.`)
}
if (!target.elements.writable && (length > 0 || growth !== undefined)) {
throw typeError(`Cannot assign to read only property '${length > 0 ? 0 : "length"}'.`)
}
return target
}
const optNumber = (value: Value): number | undefined => (value === undefined ? undefined : coerceToInteger(value))
// Callback methods fix the iteration length while reading existing elements live.
const iterate = (
@@ -213,6 +228,8 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
0,
(thisValue) => {
const target = self(thisValue, "reverse")
// Fewer than two elements means no writes at all, so a frozen one-element array reverses fine.
if (target.items.length > 1) mutable(target)
target.items.reverse()
return target
},
@@ -221,7 +238,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"sort",
1,
(thisValue, args) => {
const target = self(thisValue, "sort")
const target = mutable(self(thisValue, "sort"))
const items = target.items
const length = items.length
const holeCount = Array.from({ length }, (_, index) => Object.hasOwn(items, index)).filter((o) => !o).length
@@ -261,7 +278,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"push",
1,
(thisValue, args) => {
const target = self(thisValue, "push")
const target = mutable(self(thisValue, "push"), args.length)
// Validate all insertions before mutating to avoid partial cyclic updates.
for (const item of args) rejectCircularInsertion(target, item, "Array.push result")
return target.items.push(...args)
@@ -271,25 +288,27 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"unshift",
1,
(thisValue, args) => {
const target = self(thisValue, "unshift")
const target = mutable(self(thisValue, "unshift"), args.length)
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result")
return target.items.unshift(...args)
},
],
["pop", 0, (thisValue) => self(thisValue, "pop").items.pop()],
["shift", 0, (thisValue) => self(thisValue, "shift").items.shift()],
["pop", 0, (thisValue) => mutable(self(thisValue, "pop"), -1).items.pop()],
["shift", 0, (thisValue) => mutable(self(thisValue, "shift"), -1).items.shift()],
[
"splice",
2,
(thisValue, args) => {
const target = self(thisValue, "splice")
if (args.length === 0) return wrap(target.items.splice(0, 0))
const length = target.items.length
const start = optNumber(args[0]) ?? 0
if (args.length === 1) return wrap(target.items.splice(start))
const deleteCount = optNumber(args[1]) ?? 0
const from = start < 0 ? Math.max(length + start, 0) : Math.min(start, length)
const deleteCount =
args.length === 1 ? length - from : Math.min(Math.max(optNumber(args[1]) ?? 0, 0), length - from)
const inserted = args.slice(2)
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result")
return wrap(target.items.splice(start, deleteCount, ...inserted))
mutable(target, inserted.length - deleteCount)
return wrap(target.items.splice(from, deleteCount, ...inserted))
},
],
[
@@ -308,7 +327,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"fill",
1,
(thisValue, args) => {
const target = self(thisValue, "fill")
const target = mutable(self(thisValue, "fill"))
rejectCircularInsertion(target, args[0], "Array.fill result")
target.items.fill(args[0], optNumber(args[1]), optNumber(args[2]))
return target
@@ -318,7 +337,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"copyWithin",
2,
(thisValue, args) => {
const target = self(thisValue, "copyWithin")
const target = mutable(self(thisValue, "copyWithin"))
target.items.copyWithin(optNumber(args[0]) ?? 0, optNumber(args[1]) ?? 0, optNumber(args[2]))
return target
},
+65 -9
View File
@@ -1,13 +1,6 @@
import { Effect } from "effect"
import { constructor, methods, receiver } from "../interpreter/native.js"
import {
type AstNode,
AsyncIteratorSymbol,
invalidData,
IteratorSymbol,
rangeError,
typeError,
} from "../interpreter/model.js"
import { type AstNode, AsyncIteratorSymbol, invalidData, IteratorSymbol, typeError } from "../interpreter/model.js"
import {
define,
entries,
@@ -18,13 +11,16 @@ import {
hidden,
keys,
own,
ownKeys,
Arr,
Bytes,
Obj,
PromiseObj,
set,
coerceToString,
type Value,
} from "../interpreter/objects.js"
import { primitivePrototype } from "../interpreter/intrinsics.js"
import { containsOpaqueReference, describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { invoke, preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -63,7 +59,6 @@ export const objectAssign = <R>(ctx: Interpreter<R>, args: Array<Value>): Value
for (const key of enumerableKeys(from)) {
rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", seen)
if (!set(target, key, getOwn(from, key))) {
if (target instanceof Arr && key === "length") throw rangeError("Invalid array length")
throw typeError(`Cannot assign to read only property '${String(key)}'.`)
}
}
@@ -107,6 +102,34 @@ const classTag = (value: Value): string => {
const propertyKey = (value: Value): PropertyKey =>
value === AsyncIteratorSymbol || value === IteratorSymbol ? value : coerceToString(value)
// SetIntegrityLevel: primitives pass through. A typed array's bytes cannot carry attributes, so JS throws after
// already making it non-extensible.
const restrict = (level: "freeze" | "seal" | "preventExtensions", value: Value): Value => {
if (!(value instanceof Obj)) return value
value.extensible = false
if (level === "preventExtensions") return value
if (value instanceof Bytes && value.bytes.length > 0) {
throw typeError(`Cannot ${level} array buffer views with elements.`)
}
for (const slot of value.props.values()) {
slot.configurable = false
if (level === "freeze" && "value" in slot) slot.writable = false
}
if (value instanceof Arr) value.elements = { writable: level === "seal", enumerable: true, configurable: false }
return value
}
// TestIntegrityLevel: array elements and `length` answer through `own`, so a non-extensible empty array is sealed
// but not frozen, as in JS.
const integrity = (value: Value, frozen: boolean): boolean => {
if (!(value instanceof Obj)) return true
if (value.extensible) return false
return ownKeys(value).every((key) => {
const slot = own(value, key)
return slot !== undefined && !slot.configurable && !(frozen && "value" in slot && slot.writable)
})
}
// Object constructs identically with or without new, like JS. Only `keys` copies its result into the
// program; `values`, `entries`, `assign`, and `fromEntries` hand back the program's own values.
export const objectGlobal = <R>(ctx: Interpreter<R>) => {
@@ -166,6 +189,39 @@ export const objectGlobal = <R>(ctx: Interpreter<R>) => {
],
["assign", 2, (_, args) => objectAssign(ctx, args)],
["fromEntries", 1, (_, args) => objectFromEntries(ctx, args[0])],
["freeze", 1, (_, args) => restrict("freeze", args[0])],
["seal", 1, (_, args) => restrict("seal", args[0])],
["preventExtensions", 1, (_, args) => restrict("preventExtensions", args[0])],
["isFrozen", 1, (_, args) => integrity(args[0], true)],
["isSealed", 1, (_, args) => integrity(args[0], false)],
["isExtensible", 1, (_, args) => args[0] instanceof Obj && args[0].extensible],
[
"getPrototypeOf",
1,
(_, args) => {
if (args[0] instanceof Obj) return args[0].proto
const proto = primitivePrototype(builtins, args[0])
if (proto === undefined) {
throw typeError(`Object.getPrototypeOf cannot convert ${describeValue(args[0])} to an object.`)
}
return proto
},
],
[
"create",
2,
(_, args) => {
if (args[0] !== null && !(args[0] instanceof Obj)) {
throw typeError("Object prototype may only be an Object or null.")
}
if (args[1] !== undefined) {
throw typeError(
"Object.create property descriptors are not supported; assign the fields after creating the object.",
)
}
return new Obj(args[0])
},
],
])
define(object, "groupBy", groupBy(ctx, "Object"), hidden)
methods(builtins, builtins.Object, [
+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 => {
+78 -1
View File
@@ -1,8 +1,28 @@
import { fn, methods } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { Bytes, Obj, coerceToString } from "../interpreter/objects.js"
import {
define,
entries,
get,
getOwn,
hasOwn,
hidden,
isRuntimeReference,
Arr,
Bytes,
DateObj,
ErrorObj,
MapObj,
Obj,
RegExpObj,
SetObj,
coerceToString,
type Value,
} from "../interpreter/objects.js"
import { createErrorValue, isErrorType } from "../interpreter/intrinsics.js"
import { describeValue } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { ToolReference } from "../tool-runtime.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies. Invalid input is a
// TypeError as well; browsers throw a DOMException named InvalidCharacterError, which CodeMode does not have.
@@ -17,6 +37,63 @@ export const base64Global = <R>(ctx: Interpreter<R>, name: "atob" | "btoa") =>
}
})
// HTML structured clone over CodeMode's data kinds. Shared references survive through the memo; prototypes and
// frozen state do not, as in JS. Without a DOMException, DataCloneError is a TypeError carrying that prefix.
export const structuredCloneGlobal = <R>(ctx: Interpreter<R>) =>
fn<R>(ctx.builtins, "structuredClone", 1, (_, args) => {
if (args.length === 0) throw typeError("structuredClone requires 1 argument")
const builtins = ctx.builtins
const memo = new Map<Obj, Obj>()
const clone = (value: Value): Value => {
if (typeof value === "symbol" || value instanceof ToolReference) {
throw typeError(`DataCloneError: ${describeValue(value)} could not be cloned.`)
}
if (!(value instanceof Obj)) return value
const seen = memo.get(value)
if (seen !== undefined) return seen
const remember = <T extends Obj>(copy: T): T => {
memo.set(value, copy)
return copy
}
if (value instanceof MapObj) {
const copy = remember(new MapObj(builtins.Map))
for (const [key, item] of value.map) copy.map.set(clone(key), clone(item))
return copy
}
if (value instanceof SetObj) {
const copy = remember(new SetObj(builtins.Set))
for (const item of value.set) copy.set.add(clone(item))
return copy
}
if (value instanceof DateObj) return remember(new DateObj(builtins.Date, value.time))
if (value instanceof RegExpObj) {
return remember(new RegExpObj(builtins.RegExp, value.regex.source, value.regex.flags))
}
if (value instanceof Bytes) return remember(new Bytes(builtins.Uint8Array, new Uint8Array(value.bytes)))
if (value instanceof ErrorObj) {
const name = get(value, "name")
const message = get(value, "message")
const copy = remember(
createErrorValue(
builtins[typeof name === "string" && isErrorType(name) ? name : "Error"],
message === undefined ? undefined : coerceToString(message),
),
)
const stack = getOwn(value, "stack")
if (typeof stack === "string") define(copy, "stack", stack, hidden)
if (hasOwn(value, "cause")) define(copy, "cause", clone(getOwn(value, "cause")), hidden)
return copy
}
if (isRuntimeReference(value)) throw typeError(`DataCloneError: ${describeValue(value)} could not be cloned.`)
const copy = remember(
value instanceof Arr ? new Arr(builtins.Array, new Array(value.items.length)) : new Obj(builtins.Object),
)
for (const [key, item] of entries(value)) define(copy, key, clone(item))
return copy
}
return clone(args[0])
})
export const cryptoGlobal = <R>(ctx: Interpreter<R>) => {
const object = new Obj(ctx.builtins.Object)
methods(ctx.builtins, object, [
@@ -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.")
})
})
+330 -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,332 @@ 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"])
})
})
describe("Object.freeze, seal, and preventExtensions", () => {
test("a frozen object rejects writes, additions, and deletes with TypeErrors", async () => {
expect(
await value(`
const o = Object.freeze({ a: 1 })
const errors = []
try { o.a = 2 } catch (e) { errors.push(e.name + ": " + e.message) }
try { o.b = 2 } catch (e) { errors.push(e.name + ": " + e.message) }
try { delete o.a } catch (e) { errors.push(e.name + ": " + e.message) }
return [errors, o.a, Object.isFrozen(o), Object.isSealed(o), Object.isExtensible(o)]
`),
).toEqual([
[
"TypeError: Cannot assign to read only property 'a'.",
"TypeError: Cannot add property b, object is not extensible.",
"TypeError: Cannot delete property 'a'.",
],
1,
true,
true,
false,
])
})
test("seal keeps writes, preventExtensions keeps deletes, and each returns its argument", async () => {
expect(
await value(`
const s = { a: 1 }, p = { a: 1 }
const errors = []
Object.seal(s).a = 2
try { delete s.a } catch (e) { errors.push(e.name) }
delete Object.preventExtensions(p).a
try { p.b = 1 } catch (e) { errors.push(e.name) }
return [errors, s.a, Object.keys(p), Object.isSealed(s), Object.isFrozen(s), Object.isSealed(p), Object.isFrozen(p)]
`),
).toEqual([["TypeError", "TypeError"], 2, [], true, false, true, true])
})
test("primitives pass through, and Object.assign honors the flags", async () => {
expect(
await value(`
return [Object.freeze(1) === 1, Object.isFrozen(1), Object.isSealed("a"), Object.isExtensible(null), Object.isFrozen({}), Object.isExtensible({})]
`),
).toEqual([true, true, true, false, false, true])
const failure = await error(`Object.assign(Object.freeze({ a: 1 }), { b: 1 })`)
expect(failure.message).toContain("Cannot add property b, object is not extensible")
})
test("frozen arrays reject element, length, and mutating-method writes like JS", async () => {
expect(
await value(`
const a = Object.freeze([1, 2])
const attempt = (f) => { try { f(); return "ok" } catch (e) { return e.name + ": " + e.message } }
return [
attempt(() => a.push(3)),
attempt(() => { a[0] = 9 }),
attempt(() => { a[5] = 9 }),
attempt(() => { a.length = 0 }),
attempt(() => a.pop()),
attempt(() => a.sort()),
attempt(() => a.reverse()),
attempt(() => a.fill(0)),
attempt(() => a.copyWithin(0, 1)),
attempt(() => a.splice(0, 1)),
attempt(() => a.unshift(0)),
a, a.map((x) => x * 2), Object.isFrozen(a), Object.isFrozen(Object.freeze([])),
]
`),
).toEqual([
"TypeError: Cannot add property 2, object is not extensible.",
"TypeError: Cannot assign to read only property '0'.",
"TypeError: Cannot add property 5, object is not extensible.",
"TypeError: Cannot assign to read only property 'length'.",
"TypeError: Cannot delete property '1'.",
"TypeError: Cannot assign to read only property '0'.",
"TypeError: Cannot assign to read only property '0'.",
"TypeError: Cannot assign to read only property '0'.",
"TypeError: Cannot assign to read only property '0'.",
"TypeError: Cannot delete property '1'.",
"TypeError: Cannot add property 2, object is not extensible.",
[1, 2],
[2, 4],
true,
true,
])
})
test("sealed and non-extensible arrays allow exactly the writes JS does", async () => {
expect(
await value(`
const s = Object.seal([1, 2]), p = Object.preventExtensions([1, 2, 3])
const attempt = (f) => { try { f(); return "ok" } catch (e) { return e.name } }
const results = [attempt(() => { s[0] = 5 }), attempt(() => s.pop()), attempt(() => { s.length = 0 }), attempt(() => s.push(1))]
p.pop(); p.sort(); p.reverse(); p.fill(0, 1); p.copyWithin(0, 1); p.length = 5
results.push(attempt(() => p.push(1)), attempt(() => p.splice(0, 1, 7, 8)), p.splice(0, 1, 7), attempt(() => { p[9] = 1 }))
return [results, s, p, Object.isSealed(p), Object.isFrozen(p), Object.isSealed(Object.preventExtensions([])), Object.isFrozen(Object.preventExtensions([]))]
`),
).toEqual([
["ok", "TypeError", "TypeError", "TypeError", "TypeError", "TypeError", [0], "TypeError"],
[5, 2],
[7, 0, null, null, null],
false,
false,
true,
false,
])
})
test("typed arrays with elements cannot be frozen; wrappers freeze their properties only", async () => {
const failure = await error(`Object.freeze(new Uint8Array([1]))`)
expect(failure.message).toContain("Cannot freeze array buffer views with elements")
expect(
await value(`
const empty = Object.freeze(new Uint8Array(0))
const bytes = Object.preventExtensions(new Uint8Array([1]))
const m = Object.freeze(new Map()); m.set(1, 2)
const f = Object.freeze(() => 1)
let named = "ok"
try { f.x = 1 } catch (e) { named = e.name }
return [Object.isFrozen(empty), Object.isFrozen(bytes), Object.isExtensible(bytes), m.size, Object.isFrozen(f), named, f()]
`),
).toEqual([true, false, false, 1, true, "TypeError", 1])
})
})
describe("Object.getPrototypeOf and Object.create", () => {
test("getPrototypeOf returns the built-in prototypes for objects and primitives", async () => {
expect(
await value(`
return [
Object.getPrototypeOf([]) === Array.prototype,
Object.getPrototypeOf({}) === Object.prototype,
Object.getPrototypeOf(Object.prototype),
Object.getPrototypeOf("a") === String.prototype,
Object.getPrototypeOf(1) === Number.prototype,
Object.getPrototypeOf(true) === Boolean.prototype,
Object.getPrototypeOf(new TypeError("x")) === TypeError.prototype,
Object.getPrototypeOf(TypeError.prototype) === Error.prototype,
Object.getPrototypeOf(() => 1) === Function.prototype,
]
`),
).toEqual([true, true, null, true, true, true, true, true, true])
})
test("getPrototypeOf rejects null, undefined, and symbols", async () => {
expect((await error(`Object.getPrototypeOf(null)`)).message).toContain("cannot convert null to an object")
expect((await error(`Object.getPrototypeOf(undefined)`)).message).toContain("cannot convert undefined to an object")
expect((await error(`Object.getPrototypeOf(Symbol.iterator)`)).message).toContain("cannot convert a symbol")
})
test("Object.create links the prototype: inherited reads, in, and own-only keys", async () => {
expect(
await value(`
const p = { greet(name) { return "hi " + name }, a: 1 }
const c = Object.create(p)
c.name = "x"
const seen = []
for (const key in c) seen.push(key)
return ["greet" in c, Object.keys(c), c.hasOwnProperty("a"), c.a, c.greet(c.name), Object.getPrototypeOf(c) === p, Object.getPrototypeOf(Object.create(null)), seen]
`),
).toEqual([true, ["name"], false, 1, "hi x", true, null, ["name"]])
})
test("Object.create rejects non-object prototypes and property descriptors", async () => {
expect((await error(`Object.create(1)`)).message).toContain("Object prototype may only be an Object or null")
expect((await error(`Object.create()`)).message).toContain("Object prototype may only be an Object or null")
expect((await error(`Object.create(null, { a: { value: 1 } })`)).message).toContain(
"Object.create property descriptors are not supported",
)
expect(await value(`return Object.keys(Object.create({}, undefined))`)).toEqual([])
})
})
describe("structuredClone", () => {
test("deep-copies data values and keeps shared references shared", async () => {
expect(
await value(`
const shared = { n: 1 }
const source = { a: shared, b: shared, list: [1, , shared], map: new Map([[1, { a: [1, 2] }]]), set: new Set([shared]), when: new Date(5), bytes: new Uint8Array([1, 2]) }
const c = structuredClone(source)
c.a.n = 2
return [
c !== source, c.a === c.b, c.a !== shared, shared.n, c.list[2] === c.a, 1 in c.list, c.list.length,
c.map.get(1).a, c.map !== source.map, c.set.has(c.a), c.when.getTime(), c.when instanceof Date,
c.bytes instanceof Uint8Array, c.bytes[1], Array.isArray(c.list),
]
`),
).toEqual([true, true, true, 1, true, false, 3, [1, 2], true, true, 5, true, true, 2, true])
})
test("regexps reset lastIndex, errors keep name, message, and cause, and extras are dropped", async () => {
expect(
await value(`
const r = /a/g
r.lastIndex = 3
const e = new TypeError("boom", { cause: { code: 1 } })
e.extra = 1
const custom = new Error("x")
custom.name = "Custom"
const [cr, ce, cc] = [structuredClone(r), structuredClone(e), structuredClone(custom)]
return [cr.source, cr.flags, cr.lastIndex, ce instanceof TypeError, ce.name, ce.message, ce.cause, ce.cause !== e.cause, ce.extra, Object.keys(ce), cc.name]
`),
).toEqual(["a", "g", 0, true, "TypeError", "boom", { code: 1 }, true, null, [], "Error"])
})
test("the clone is a plain extensible object: prototypes, symbols, undefined fields, and frozen state drop", async () => {
expect(
await value(`
const c = structuredClone(Object.freeze(Object.assign(Object.create({ inherited: 1 }), { a: undefined, [Symbol.iterator]: 1, b: 2 })))
return [Object.isFrozen(c), Object.getPrototypeOf(c) === Object.prototype, "a" in c, Symbol.iterator in c, c.b, c.inherited]
`),
).toEqual([false, true, true, false, 2, null])
})
test("functions, symbols, promises, wrappers, and tool references throw a DataCloneError TypeError", async () => {
for (const code of [
`structuredClone(() => 1)`,
`structuredClone({ deep: [Symbol.iterator] })`,
`structuredClone(Promise.resolve(1))`,
`structuredClone(new URL("http://x"))`,
`structuredClone(tools)`,
]) {
const failure = await error(code)
expect(failure.message).toMatch(/^TypeError: DataCloneError: .* could not be cloned\./)
}
expect(await value(`try { structuredClone(() => 1) } catch (e) { return e.name }`)).toBe("TypeError")
expect((await error(`structuredClone()`)).message).toContain("structuredClone requires 1 argument")
})
})
+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
@@ -24,7 +24,7 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
`script/sync-test262.ts` skips a file when its frontmatter declares a `flags`, `features`, or `includes` value the
manifest marks unsupported, or when its code matches one of the manifest's `boundaries` patterns. The sync checks the
checkout is at the pinned revision, so every machine runs the same 3993 files. Boundaries are
checkout is at the pinned revision, so every machine runs the same 4595 files. Boundaries are
intentional limits of the interpreter, not compatibility work: classes, `this`, `arguments`, prototype objects,
property descriptors, accessors, boxed primitives, sloppy mode, `eval`, `Symbol()`, and the `$262` host API. If one
of those decisions changes, delete its entry and re-sync; the tests are upstream, not lost.
+14 -2
View File
@@ -1,6 +1,18 @@
{
"revision": "250f204f23a9249ff204be2baec29600faae7b75",
"directories": ["built-ins/Array/prototype", "language/statements", "built-ins/Iterator"],
"directories": [
"built-ins/Array/prototype",
"built-ins/Iterator",
"built-ins/Object/freeze",
"built-ins/Object/getPrototypeOf",
"built-ins/Object/isExtensible",
"built-ins/Object/isFrozen",
"built-ins/Object/isSealed",
"built-ins/Object/preventExtensions",
"built-ins/String/raw",
"language/expressions/tagged-template",
"language/statements"
],
"harness": ["assert.js", "sta.js", "compareArray.js", "doneprintHandle.js"],
"flags": ["module", "raw", "noStrict"],
"boundaries": {
@@ -9,7 +21,7 @@
"arguments": "\\barguments\\b",
"call/apply/bind": "\\.(call|apply|bind)\\s*\\(",
"accessor properties": "\\b(get|set)\\s+[\\w$\\[][^\\n(]*\\(",
"property descriptors": "Object\\.(defineProperty|defineProperties|getOwnPropertyDescriptors?|getOwnPropertyNames|create|getPrototypeOf|setPrototypeOf|freeze|seal|preventExtensions|isFrozen|isSealed|isExtensible)\\b",
"property descriptors": "Object\\.(defineProperty|defineProperties|getOwnPropertyDescriptors?|getOwnPropertyNames|setPrototypeOf)\\b",
"boxed primitives": "\\bnew\\s+(String|Number|Boolean)\\s*\\(",
"sloppy mode": "\\bwith\\s*\\(",
"eval": "\\b(eval|Function)\\b",
+21 -1
View File
@@ -195,10 +195,27 @@ built-ins/Array/prototype/unshift/S15.4.4.13_A3_T2.js # Array.prototype.unshift
built-ins/Array/prototype/unshift/S15.4.4.13_A4_T1.js # Array.prototype.unshift called on incompatible receiver a data object.
built-ins/Array/prototype/unshift/S15.4.4.13_A4_T2.js # #3: Array.prototype[0] = 1; x = []; x.length = 1; x.unshift(0); x[1] === 1. Actual: undefined
built-ins/Iterator/from/return-method-returns-iterator-result.js # Iterator next must be a function.
built-ins/Iterator/prototype/Symbol.iterator/is-function.js # every built-in collection iterator shares one prototype, so its parent is Object.prototype
built-ins/Iterator/prototype/drop/limit-tonumber-throws.js # Expected a Test262Error but got a RangeError
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.
built-ins/Object/freeze/throws-when-false.js # Unknown identifier 'Proxy'.
built-ins/Object/getPrototypeOf/15.2.3.2-2-12.js # Object.getPrototypeOf(EvalError) Expected SameValue(«», «Error») to be true
built-ins/Object/getPrototypeOf/15.2.3.2-2-13.js # Object.getPrototypeOf(RangeError) Expected SameValue(«», «Error») to be true
built-ins/Object/getPrototypeOf/15.2.3.2-2-14.js # Object.getPrototypeOf(ReferenceError) Expected SameValue(«», «Error») to be true
built-ins/Object/getPrototypeOf/15.2.3.2-2-15.js # Object.getPrototypeOf(SyntaxError) Expected SameValue(«», «Error») to be true
built-ins/Object/getPrototypeOf/15.2.3.2-2-16.js # Object.getPrototypeOf(TypeError) Expected SameValue(«», «Error») to be true
built-ins/Object/getPrototypeOf/15.2.3.2-2-17.js # Object.getPrototypeOf(URIError) Expected SameValue(«», «Error») to be true
built-ins/Object/getPrototypeOf/15.2.3.2-2-2.js # … cannot be constructed: user-defined constructors and classes are not supported.
built-ins/Object/isExtensible/15.2.3.13-2-24.js # … cannot be constructed: user-defined constructors and classes are not supported.
built-ins/Object/isExtensible/15.2.3.13-2-25.js # … cannot be constructed: user-defined constructors and classes are not supported.
built-ins/Object/isExtensible/15.2.3.13-2-26.js # … cannot be constructed: user-defined constructors and classes are not supported.
built-ins/Object/isExtensible/15.2.3.13-2-27.js # … cannot be constructed: user-defined constructors and classes are not supported.
built-ins/Object/preventExtensions/15.2.3.10-3-24.js # … cannot be constructed: user-defined constructors and classes are not supported.
built-ins/Object/preventExtensions/throws-when-false.js # Unknown identifier 'Proxy'.
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 +230,8 @@ 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-in/S12.6.4_A7_T2.js # for...in visits keys deleted during the loop
language/statements/for-in/order-enumerable-shadowed.js # Object.create property descriptors are not supported; assign the fields after creating the object.
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
@@ -251,8 +269,10 @@ language/statements/function/S13.2_A4_T1.js # Unknown identifier '…'.
language/statements/function/S13.2_A4_T2.js # #1: typeof __gunc.prototype === '…'. Actual: typeof __gunc.prototype ===undefined
language/statements/function/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/function/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/generators/default-proto.js # generator functions have no GeneratorFunction.prototype
language/statements/generators/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/generators/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/generators/prototype-relation-to-function.js # generator functions have no GeneratorFunction.prototype
language/statements/labeled/value-await-non-module.js # Failed to parse TypeScript: Expression expected.
language/statements/let/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/return/S12.9_A1_T1.js # expected SyntaxError but the program ran
+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(),
+16 -13
View File
@@ -1102,8 +1102,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 +1128,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 +1531,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 +1556,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": {