mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-23 17:17:43 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d238165a2 | ||
|
|
68b28bdb98 | ||
|
|
150dc69e4b | ||
|
|
d5d4461e67 | ||
|
|
d56ce74373 | ||
|
|
17abc5906b | ||
|
|
8656838a5b | ||
|
|
53179daefa | ||
|
|
bab26d63ea | ||
|
|
f0381e5da3 | ||
|
|
740072694d | ||
|
|
2e4abeb25d | ||
|
|
43f1dad8e1 | ||
|
|
cf4b4c2312 | ||
|
|
3bf8a5a8cf | ||
|
|
ddeb19790a | ||
|
|
fe0d1682ca | ||
|
|
1746672c42 | ||
|
|
3a2203eaac | ||
|
|
38c320ea4c | ||
|
|
5c53cfc342 | ||
|
|
8683406690 | ||
|
|
126294a322 | ||
|
|
d2bbefbac8 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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" },
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 }>(),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>> & {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user