Compare commits

..
33 changed files with 706 additions and 180 deletions

No files matched your search

-3
View File
@@ -975,7 +975,6 @@
"fuzzysort": "catalog:",
"katex": "0.16.47",
"marked": "catalog:",
"marked-katex-extension": "5.1.13",
"marked-shiki": "catalog:",
"motion": "12.34.5",
"remeda": "catalog:",
@@ -4628,8 +4627,6 @@
"marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="],
"marked-katex-extension": ["marked-katex-extension@5.1.13", "", { "peerDependencies": { "katex": ">=0.16 <0.19", "marked": ">=4 <19" } }, "sha512-+6gVygGg5at9g+YsuIQZk7XcAc6Pg1P0grEJKEWvF7DAwceZ7ZIvU0fhQe6auUqSbnPmSjJxZuICdSCdbGaFRw=="],
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-m8XFudp6AwHWDZO7rDXCTkNF0XzgdnWUAg0VCgmGoDQ=",
"aarch64-linux": "sha256-CQmS4coITqsMmtgi/g4Xivy/d6sDIklAE8duCUkAazs=",
"aarch64-darwin": "sha256-fSeacvDBLUCSKIeGCTgZsmqfo2rohARHvFgYLaNXHi8="
"x86_64-linux": "sha256-jTkP2Y1E9CHl/HChpAmdTovdOTBEkotuY2B2GARDdEA=",
"aarch64-linux": "sha256-P38IIeyyHeaScT9OMaQJ+lrjKrdN1l/c7r669fwMQjE=",
"aarch64-darwin": "sha256-mx5ZyXuMoo3jhbo31kgqBHCkg7KjD/e2DY7UGEvxzfs="
}
}
+8 -13
View File
@@ -1,5 +1,5 @@
import { Effect, Option } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema/index.js"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared.js"
import { parse } from "./partial-json.js"
@@ -79,16 +79,6 @@ const inputDelta = (tool: PendingTool, text: string): LLMEvent => {
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
return parseToolInput(route, tool.name, raw).pipe(
Effect.catch((error) =>
tool.providerExecuted
? Effect.fail(error)
: Effect.succeed(
Option.getOrElse(
Option.map(parsePartialInput(raw), (input) => input ?? {}),
() => ({}),
),
),
),
Effect.map(
(input): ToolCall =>
LLMEvent.toolCall({
@@ -100,10 +90,15 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
providerMetadata: tool.providerMetadata,
}),
),
Effect.catch((error) =>
tool.providerExecuted
? Effect.fail(error)
: Effect.succeed(LLMEvent.toolInputError({ id: tool.id, name: tool.name, namespace: tool.namespace, raw })),
),
)
}
const finishEvents = (tool: PendingTool, event: ToolCall): ReadonlyArray<LLMEvent> => [
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> => [
LLMEvent.toolInputEnd({
id: tool.id,
name: tool.name,
@@ -206,7 +201,7 @@ export const appendExisting = <K extends StreamKey>(
/**
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
* from state, and recover incomplete local arguments when needed.
* from state, and reject malformed local arguments without repairing them.
* Missing keys are a no-op because some providers emit stop events for
* non-tool content blocks.
*/
+1 -1
View File
@@ -91,7 +91,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
provider: id,
message: "Google Vertex tuned models do not support Express Mode API keys",
})
const location = GoogleVertexShared.location(inputLocation, "us-central1")
const location = GoogleVertexShared.location(inputLocation, "global")
const project = GoogleVertexShared.project(inputProject)
const endpoint =
baseURL ??
@@ -1131,7 +1131,7 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("recovers incomplete tool input at finalization", () =>
it.effect("rejects incomplete tool input at finalization", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
@@ -1148,10 +1148,11 @@ describe("Bedrock Converse route", () => {
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.find((event) => event.type === "tool-call")).toMatchObject({
expect(response.toolCalls).toEqual([])
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
id: "tool_1",
name: "lookup",
input: { query: "partial" },
raw: '{"query":"partial',
})
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
}),
@@ -4507,7 +4507,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("recovers authoritative incomplete final function arguments", () =>
it.effect("rejects authoritative incomplete final function arguments", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4533,17 +4533,17 @@ describe("OpenAI Responses route", () => {
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
expect(response.toolCalls).toEqual([])
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
id: "call_1",
name: "lookup",
input: { query: "partial" },
raw: '{"query":"partial',
})
expect(response.finishReason.normalized).toBe("tool-calls")
expect(response.events.some(LLMEvent.is.toolInputError)).toBeFalse()
}),
)
it.effect("recovers incomplete function arguments when output_item.added is absent", () =>
it.effect("rejects incomplete function arguments when output_item.added is absent", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4560,10 +4560,11 @@ describe("OpenAI Responses route", () => {
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
expect(response.toolCalls).toEqual([])
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
id: "call_1",
name: "lookup",
input: { query: "partial" },
raw: '{"query":"partial',
})
expect(response.finishReason.normalized).toBe("tool-calls")
}),
+8 -8
View File
@@ -126,7 +126,7 @@ describe("ToolStream", () => {
}),
)
it.effect("finalizes incomplete local input using the partial JSON parser", () =>
it.effect("rejects incomplete final local input", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
@@ -139,13 +139,13 @@ describe("ToolStream", () => {
tools: {},
events: [
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "partial" } },
{ type: "tool-input-error", id: "call_1", name: "lookup", raw: '{"query":"partial' },
],
})
}),
)
it.effect("repairs malformed string escapes in final local input", () =>
it.effect("rejects malformed string escapes in final local input", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
@@ -156,12 +156,12 @@ describe("ToolStream", () => {
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { path: "A\\H", text: "first\tsecond" } },
{ type: "tool-input-error", id: "call_1", name: "lookup", raw: '{"path":"A\\H","text":"first\tsecond"}' },
])
}),
)
it.effect("defaults unrecoverable local input to an empty object", () =>
it.effect("rejects invalid final local input instead of defaulting to an empty object", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
@@ -172,12 +172,12 @@ describe("ToolStream", () => {
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "tool-input-error", id: "call_1", name: "lookup", raw: "invalid" },
])
}),
)
it.effect("recovers incomplete input alongside valid parallel tool calls", () =>
it.effect("rejects incomplete input without dropping valid parallel tool calls", () =>
Effect.gen(function* () {
const valid = ToolStream.start(ToolStream.empty<number>(), 0, {
id: "call_valid",
@@ -197,7 +197,7 @@ describe("ToolStream", () => {
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
{ type: "tool-input-end", id: "call_invalid", name: "lookup" },
{ type: "tool-call", id: "call_invalid", name: "lookup", input: { query: "partial" } },
{ type: "tool-input-error", id: "call_invalid", name: "lookup", raw: '{"query":"partial' },
],
})
}),
+29 -2
View File
@@ -1,9 +1,9 @@
import { expect, test, type Page } from "@playwright/test"
import { holdRoute, NO_PROVIDER, project, REMOTE_SERVER, seed, sessionHref } from "../utils/app"
import { expectPath, holdRoute, NO_PROVIDER, project, REMOTE_SERVER, seed, sessionHref } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { mockRemoteServer } from "../utils/workspace"
import { expectAppVisible } from "../utils/waits"
import { APP_READY_TIMEOUT, expectAppVisible } from "../utils/waits"
test.use({ serviceWorkers: "block" })
@@ -77,6 +77,33 @@ test("the session context menu renames, exports, and deletes a Home session", as
await expect(renamedRow).toBeHidden()
})
test("the Home shortcut focuses session search, and the Home button leaves focus alone", async ({ page }) => {
await mockStressTimeline(page)
await seed(page, {
projects: { local: [{ worktree: fixture.directory, expanded: true }] },
lastProject: { local: fixture.directory },
tabs: [fixture.sourceID],
})
await page.goto(sessionHref(fixture.sourceID))
const editor = page.locator('[data-component="composer-editor"]')
await expect(editor).toBeEditable({ timeout: APP_READY_TIMEOUT })
await editor.click()
await page.keyboard.press("ControlOrMeta+b")
const search = page.getByRole("textbox", { name: /Search sessions/ })
await expect(search).toBeFocused()
await page.keyboard.type("jump")
await expect(page.getByRole("option")).toHaveCount(1)
await expect(page.getByRole("option")).toContainText(fixture.expected.targetTitle)
await page.keyboard.press("ControlOrMeta+b")
await expectPath(page, sessionHref(fixture.sourceID))
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(search).toHaveValue("")
await expect(search).not.toBeFocused()
})
test("Home shows loaded sessions before the location request resolves", async ({ page }) => {
const location = await holdRoute(page, (url) => url.pathname === "/api/location")
await openHome(page)
@@ -737,15 +737,30 @@ test("/compact runs with the composer model and shows a queued compaction, as in
expect(models).toMatchObject([{ model: { id: "queue-model", providerID: "opencode" } }])
})
for (const action of ["Move to queue", "Delete"] as const) {
test(`${action} on a pending steer replaces Revert, as in the TUI`, async ({ page }) => {
const mock = createQueueMock(["U2: Also check the retry path."])
for (const [action, status] of [
["Move to queue", "steering"],
["Delete", "steering"],
["Move to queue", "starting"],
["Delete", "starting"],
] as const) {
test(`${action} on a ${status} pending steer replaces Revert, as in the TUI`, async ({ page }) => {
// A steer waits behind work only once the running execution delivered input; before that it is starting.
const delivered: SessionMessageInfo[] =
status === "steering"
? [{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } }]
: []
const mock = createQueueMock(["U2: Also check the retry path."], delivered)
const inboxID = mock.rows[0].id
mock.rows[0].delivery = "steer"
const view = await openQueue(page, mock)
const pending = userRow(page, inboxID)
await expect(pending).toContainText("U2: Also check the retry path.")
const message = pending.locator('[data-component="user-message"]')
if (status === "steering") await expect(message).toHaveAttribute("data-pending", "true")
else await expect(message).not.toHaveAttribute("data-pending")
await pending.hover()
await expect(pending.getByRole("button", { name: "Revert message" })).toHaveCount(0)
await pending.getByRole("button", { name: action }).click()
+14 -3
View File
@@ -1,10 +1,11 @@
import { useCommand } from "@/shell/commands/command"
import { useLayout } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { serverName } from "@/runtime/server/registry"
import { displayName } from "@opencode/ui/project-avatar"
import { sessionLabel } from "@/session/title"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMemo, onCleanup } from "solid-js"
import { createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "../model"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./controller"
@@ -15,12 +16,19 @@ type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) {
const command = useCommand()
const language = useLanguage()
const layout = useLayout()
const [state, setState] = createStore({
const [state, setState] = createStore<{
value: string
focused: boolean
highlighted: string
exact: HomeSessionRecord | undefined
lookingUp: boolean
}>({
value: "",
focused: false,
highlighted: "",
exact: undefined as HomeSessionRecord | undefined,
exact: undefined,
lookingUp: false,
})
@@ -78,6 +86,9 @@ export function createHomeSessionSearchController(home: HomeController, sessions
}),
)
onCleanup(() => lookup++)
onMount(() => {
if (layout.home.searchFocus.take()) focus()
})
command.register("home.search", () => [
{
+2 -6
View File
@@ -195,14 +195,10 @@ export function createActiveSessionRegion(input: {
}
const pendingSteer: NonNullable<SessionUserActions["pending"]> = {
steer: (messageID) => {
status: (messageID) => {
const sessionID = input.session.identity.params.id
if (!sessionID) return false
return data.session.pending
.list(sessionID)
.some((item) => item.id === messageID && item.type === "user" && item.delivery === "steer")
return sessionID ? data.session.pending.status(sessionID, messageID) : undefined
},
queue: ({ sessionID, messageID }) =>
server.api.session.inbox
+14
View File
@@ -214,6 +214,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
sessionTabPreview: {},
})
// Opening Home with its shortcut focuses session search, so typing filters immediately.
let homeSearchFocus = false
// Names of other session-scoped stores, e.g. extension storage, so pruning drops them with the layout state.
const [scoped, setScoped, , scopedReady] = persisted(
Persist.global("layout.scoped"),
@@ -355,6 +358,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setSelection(selection: HomeProjectSelection) {
setStore("home", "selection", reconcile(selection))
},
searchFocus: {
request() {
homeSearchFocus = true
},
take() {
const requested = homeSearchFocus
homeSearchFocus = false
return requested
},
},
},
sessionState: {
/** Records a session-scoped store, so pruning a session's layout state drops it too. */
+4 -1
View File
@@ -452,7 +452,10 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
category: language.t("command.category.view"),
keybind: windows() ? "alt+home" : "mod+b",
hidden: true,
onSelect: toggleHome,
onSelect: () => {
if (layout.route().type !== "home") layout.home.searchFocus.request()
toggleHome()
},
},
])
+23
View File
@@ -52,6 +52,12 @@ import type { SessionInbox } from "@opencode/schema/session-inbox"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
/**
* Where a pending user prompt stands relative to the runner: `starting` is promoted at the next idle boundary,
* `steering` waits for the next step boundary of an execution that already delivered input, `stranded` outlived an
* execution that ended without delivering it, and `queued` waits for every steer to drain.
*/
export type DataPendingStatus = "starting" | "steering" | "stranded" | "queued"
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
export type CreateDataInput = {
@@ -1400,6 +1406,23 @@ export function createData(config: CreateDataInput) {
list(sessionID: string) {
return store.session.pending[sessionID] ?? []
},
// An execution's idle boundary promotes every pending steer at once, so a steer only waits behind work
// once the current execution has delivered input.
status(sessionID: string, inboxID: string): DataPendingStatus | undefined {
const pending = store.session.pending[sessionID] ?? []
const item = pending.find((entry) => entry.id === inboxID && entry.type === "user")
if (!item) return undefined
if (item.delivery === "queue") return "queued"
const messages = store.session.message[sessionID] ?? []
const boundary = messages.findLastIndex((entry) => entry.type === "idle")
if ((store.session.active[sessionID] ?? "idle") === "idle")
return boundary >= 0 && item.time.created <= messages[boundary].time.created ? "stranded" : "starting"
return messages
.slice(boundary + 1)
.some((entry) => entry.type === "user" && !pending.some((other) => other.id === entry.id))
? "steering"
: "starting"
},
sync(sessionID: string) {
return sync.run(`session.pending:${sessionID}`, async () => {
const updates = new Map<string, SessionInboxInfo | SessionInbox.Delivery | undefined>()
@@ -0,0 +1,180 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent } from "../src/promise"
const sessionID = "ses_pending"
function fixture() {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
return request.method === "GET" ? Response.json({ data: [] }) : Response.json({ data: {} })
},
})
let seq = 0
return createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
})
const emit = (type: string, payload: Record<string, unknown> = {}) => {
seq++
const event = {
id: `evt_${seq}`,
type,
created: seq,
durable: { aggregateID: sessionID, seq, version: 1 },
data: { sessionID, ...payload },
} as OpenCodeEvent
listeners.forEach((listener) => listener({ name: event.type, details: event }))
}
return {
data,
dispose,
enqueue: (id: string, delivery: "steer" | "queue" = "steer") =>
emit("session.inbox.enqueued", { inboxID: id, item: { type: "user", delivery, payload: { text: id } } }),
deliver: (id: string) => emit("session.inbox.delivered", { inboxID: id }),
started: () => emit("session.execution.started"),
settled: (outcome: "succeeded" | "failed" | "interrupted") =>
emit(
`session.execution.${outcome}`,
outcome === "failed"
? { error: { type: "provider", message: "Provider unavailable" } }
: outcome === "interrupted"
? { reason: "user" }
: {},
),
move: (id: string, delivery: "steer" | "queue") =>
emit("session.inbox.delivery.changed", { inboxID: id, delivery }),
status: (id: string) => data.session.pending.status(sessionID, id),
}
})
}
test("an idle send is starting from optimistic admission until delivery", async () => {
const setup = fixture()
try {
// The composer marks the Session running in the same task as optimistic admission.
const sending = setup.data.session.prompt({ sessionID, id: "msg_first", text: "first" })
expect(setup.status("msg_first")).toBe("starting")
setup.data.session.setStatus(sessionID, "running")
expect(setup.status("msg_first")).toBe("starting")
await sending
setup.enqueue("msg_first")
expect(setup.status("msg_first")).toBe("starting")
setup.started()
expect(setup.status("msg_first")).toBe("starting")
setup.deliver("msg_first")
expect(setup.status("msg_first")).toBeUndefined()
expect(setup.data.session.message.get(sessionID, "msg_first")?.type).toBe("user")
} finally {
setup.dispose()
}
})
test("a steer sent after the execution delivered input is steering, and follows delivery changes", () => {
const setup = fixture()
try {
setup.enqueue("msg_first")
setup.started()
setup.deliver("msg_first")
setup.enqueue("msg_steer")
expect(setup.status("msg_steer")).toBe("steering")
setup.move("msg_steer", "queue")
expect(setup.status("msg_steer")).toBe("queued")
setup.move("msg_steer", "steer")
expect(setup.status("msg_steer")).toBe("steering")
setup.deliver("msg_steer")
expect(setup.status("msg_steer")).toBeUndefined()
} finally {
setup.dispose()
}
})
test("rapid follow-ups join the starting execution until it delivers without them", () => {
const setup = fixture()
try {
setup.enqueue("msg_first")
setup.started()
setup.enqueue("msg_second")
// The idle boundary promotes every pending steer, so both are starting.
expect(setup.status("msg_first")).toBe("starting")
expect(setup.status("msg_second")).toBe("starting")
// Promotion happened before the second admission landed; it now steers the running execution.
setup.deliver("msg_first")
expect(setup.status("msg_second")).toBe("steering")
} finally {
setup.dispose()
}
})
test.each(["failed", "interrupted"] as const)("an undelivered steer is stranded after execution %s", (outcome) => {
const setup = fixture()
try {
setup.enqueue("msg_first")
setup.started()
expect(setup.status("msg_first")).toBe("starting")
setup.settled(outcome)
expect(setup.data.session.status(sessionID)).toBe("idle")
expect(setup.status("msg_first")).toBe("stranded")
// A new idle send starts fresh, and its execution promotes the stranded steer with it.
setup.enqueue("msg_retry")
expect(setup.status("msg_retry")).toBe("starting")
expect(setup.status("msg_first")).toBe("stranded")
setup.started()
expect(setup.status("msg_first")).toBe("starting")
expect(setup.status("msg_retry")).toBe("starting")
} finally {
setup.dispose()
}
})
test("a later execution starts fresh after an earlier turn delivered input", () => {
const setup = fixture()
try {
setup.enqueue("msg_first")
setup.started()
setup.deliver("msg_first")
setup.settled("succeeded")
setup.enqueue("msg_next")
expect(setup.status("msg_next")).toBe("starting")
setup.started()
expect(setup.status("msg_next")).toBe("starting")
} finally {
setup.dispose()
}
})
test("a remote idle prompt is starting", () => {
const setup = fixture()
try {
setup.enqueue("msg_remote")
expect(setup.status("msg_remote")).toBe("starting")
setup.started()
expect(setup.status("msg_remote")).toBe("starting")
} finally {
setup.dispose()
}
})
test("a queued prompt is queued regardless of execution state", () => {
const setup = fixture()
try {
setup.enqueue("msg_queued", "queue")
expect(setup.status("msg_queued")).toBe("queued")
setup.started()
expect(setup.status("msg_queued")).toBe("queued")
} finally {
setup.dispose()
}
})
+11 -11
View File
@@ -144,19 +144,19 @@ export const layer = (options?: Options) =>
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).at(-1)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
.resolve(entry, variables)
.pipe(
Effect.catch(() =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
Effect.as([] as const),
),
const variables = auth && credential?.value.type === "key" ? { [auth.env]: credential.value.key } : undefined
// A failed refresh, including a missing credential, keeps the last resolved config so remote providers
// and allowlists do not disappear; only removing the source clears it.
const configs = yield* (
variables ? wellknown.resolve(entry, variables) : Effect.fail(new Error("No usable wellknown credential"))
).pipe(
Effect.catch((error) =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin, error }).pipe(
Effect.andThen(wellknown.cached(entry.origin)),
),
)
),
)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
@@ -20,7 +20,7 @@ function resolveLocation(options: Record<string, any>) {
process.env.GOOGLE_VERTEX_LOCATION ??
process.env.GOOGLE_CLOUD_LOCATION ??
process.env.VERTEX_LOCATION ??
"us-central1"
"global"
)
}
+9
View File
@@ -60,6 +60,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode/ai/providers/alibaba/messages", () => import("@opencode/ai/providers/alibaba/messages")],
["@opencode/ai/providers/alibaba/responses", () => import("@opencode/ai/providers/alibaba/responses")],
["@opencode/ai/providers/anthropic", () => import("@opencode/ai/providers/anthropic")],
["@opencode/ai/providers/anthropic-compatible", () => import("@opencode/ai/providers/anthropic-compatible")],
["@opencode/ai/providers/azure", () => import("@opencode/ai/providers/azure")],
["@opencode/ai/providers/azure/chat", () => import("@opencode/ai/providers/azure/chat")],
["@opencode/ai/providers/azure/responses", () => import("@opencode/ai/providers/azure/responses")],
@@ -95,6 +96,14 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode/ai/providers/openai/chat", () => import("@opencode/ai/providers/openai/chat")],
["@opencode/ai/providers/openai/responses", () => import("@opencode/ai/providers/openai/responses")],
["@opencode/ai/providers/openai-compatible", () => import("@opencode/ai/providers/openai-compatible")],
[
"@opencode/ai/providers/openai-compatible/responses",
() => import("@opencode/ai/providers/openai-compatible/responses"),
],
[
"@opencode/ai/providers/openai-compatible-responses",
() => import("@opencode/ai/providers/openai-compatible-responses"),
],
["@opencode/ai/providers/openrouter", () => import("@opencode/ai/providers/openrouter")],
["@opencode/ai/providers/togetherai", () => import("@opencode/ai/providers/togetherai")],
["@opencode/ai/providers/venice", () => import("@opencode/ai/providers/venice")],
@@ -395,7 +395,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(
(error: SessionError.Error, scope: "hosted" | "all" = "all") => failTools(error, scope),
(error: SessionError.Error, scope: "hosted" | "all" | "uncalled" = "all") => failTools(error, scope),
)
const publish = Effect.fnUntraced(function* (event: LLMEvent) {
+7 -1
View File
@@ -61,6 +61,11 @@ interface Input {
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
const INPUT_INCOMPLETE = {
type: "tool.input-incomplete",
message:
"Tool call arguments were not completed and were not executed. Re-issue the tool call with complete arguments.",
} as const
/** Captures Location-scoped dependencies without introducing another service or execution loop. */
export const make = Effect.gen(function* () {
@@ -216,10 +221,11 @@ export const make = Effect.gen(function* () {
if (toolFailure) yield* publisher.failUnsettledTools(toolFailure)
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
// All local fibers have joined; only provider-hosted results can still be missing.
// Parsers may leave unfinished calls without an execution event.
if (llmError || (Exit.isSuccess(stream) && !recorded.providerFailed)) {
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
yield* publisher.failUnsettledTools(INPUT_INCOMPLETE, "uncalled")
}
const record = publisher.record()
+6 -13
View File
@@ -8,7 +8,6 @@ export * as WriteTool from "./write.js"
import type { Context } from "@opencode/plugin/effect/plugin"
import { ToolFailure } from "@opencode/ai"
import { FileDiff } from "@opencode/schema/file-diff"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode/util/bom"
import { Environment } from "../../environment/index.js"
@@ -33,7 +32,6 @@ export const Output = Schema.Struct({
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
files: Schema.Array(FileDiff.Info),
})
export type Output = typeof Output.Type
@@ -87,18 +85,13 @@ export const Plugin = {
source,
})
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
const written = yield* FileMutation.readText(environment.files, target.absolute)
const formatted = (yield* formatter.file(target.absolute))
? yield* FileMutation.syncTextBom(environment.files, target.absolute, written.bom)
: written.text
return {
...result,
files: [
fileDiff(result.resource, current?.text ?? "", formatted, result.existed ? "modified" : "added"),
],
} satisfies Output
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
if (yield* formatter.file(target.absolute)) {
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
}
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelContent(output), metadata: { files: output.files } })),
Effect.map((output) => ({ output, content: toModelContent(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
+33 -4
View File
@@ -48,7 +48,10 @@ export interface Interface {
readonly refresh: () => Effect.Effect<boolean, Error>
readonly add: (origin: string) => Effect.Effect<Entry, Error>
readonly remove: (origin: string) => Effect.Effect<void>
/** Resolves and caches the configuration for a registered source. */
readonly resolve: (entry: Entry, variables: Readonly<Record<string, string>>) => Effect.Effect<Config[], Error>
/** Returns the last configuration resolved for an origin, kept until the source is removed. */
readonly cached: (origin: string) => Effect.Effect<Config[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/WellKnown") {}
@@ -97,6 +100,9 @@ const resolveEntry = Effect.fnUntraced(function* (entry: Entry, variables: Reado
const sourcesKey = "wellknown:sources"
const Sources = Schema.Array(Schema.String)
const manifestKey = (origin: string) => `wellknown:manifest:${origin}`
const configKey = (origin: string) => `wellknown:config:${origin}`
const Configs = Schema.Array(Config)
const layer = Layer.effect(
Service,
@@ -107,7 +113,22 @@ const layer = Layer.effect(
const cache = yield* Ref.make(new Map<string, Entry>())
const lock = Semaphore.makeUnsafe(1)
const loadEntry = Effect.fn("WellKnown.loadEntry")(function* (origin: string) {
const manifest = yield* inspect(origin).pipe(Effect.provideService(HttpClient.HttpClient, http))
const manifest = yield* inspect(origin).pipe(
Effect.provideService(HttpClient.HttpClient, http),
Effect.tap((manifest) => kv.set(manifestKey(origin), manifest)),
// An unreachable source keeps its last manifest so its remote config still loads after a restart.
Effect.catch((error) =>
kv
.get(manifestKey(origin))
.pipe(
Effect.flatMap((cached) =>
Schema.is(Manifest)(cached)
? Effect.logWarning("failed to load wellknown manifest", { origin, error }).pipe(Effect.as(cached))
: Effect.fail(error),
),
),
),
)
return { origin, integrationID: Integration.ID.make(origin), manifest }
})
@@ -167,6 +188,8 @@ const layer = Layer.effect(
sourcesKey,
origins.filter((item) => item !== origin),
)
yield* kv.remove(manifestKey(origin))
yield* kv.remove(configKey(origin))
yield* Ref.update(cache, (current) => {
const next = new Map(current)
next.delete(origin)
@@ -176,9 +199,15 @@ const layer = Layer.effect(
},
(effect, _value) => lock.withPermit(effect),
),
resolve: Effect.fn("WellKnown.resolveEntry")((entry, variables) =>
resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http)),
),
resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) {
const configs = yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http))
yield* kv.set(configKey(entry.origin), configs)
return configs
}),
cached: Effect.fn("WellKnown.cached")(function* (origin) {
const value = yield* kv.get(configKey(origin))
return Schema.is(Configs)(value) ? [...value] : []
}),
})
}),
)
+143 -1
View File
@@ -4,7 +4,7 @@ import { describe, expect, test } from "bun:test"
import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode/core/config"
import { Directory, Document, Event, Info } from "@opencode/schema/config"
import { Directory, Document, type Entry, Event, Info } from "@opencode/schema/config"
import { ConfigModel } from "@opencode/schema/config/model"
import { ConfigProvider } from "@opencode/schema/config/provider"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
@@ -21,6 +21,7 @@ import { Project } from "@opencode/core/project"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { WellKnown } from "@opencode/core/wellknown"
import { KV } from "@opencode/core/kv"
import { Integration } from "@opencode/schema/integration"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { location } from "../fixture/location"
@@ -512,6 +513,7 @@ describe("Config", () => {
available
? Effect.succeed([{ shell: variables.TOKEN }])
: Effect.fail(new Error("expired credential")),
cached: () => Effect.succeed([]),
}),
),
deps: [],
@@ -550,6 +552,145 @@ describe("Config", () => {
),
)
it.live("keeps the last resolved wellknown config across failures and restarts until its source is removed", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
let manifestStatus = 200
let configStatus = 200
let requests = 0
let key: string | undefined = "secret"
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/.well-known/opencode") {
if (manifestStatus !== 200) return new Response("Unavailable", { status: manifestStatus })
return Response.json({
auth: { command: ["login"], env: "TOKEN" },
remote_config: { url: `${url.origin}/config`, headers: { authorization: "Bearer {env:TOKEN}" } },
})
}
requests++
if (configStatus !== 200) return new Response("Unavailable", { status: configStatus })
return Response.json({
config: {
enabled_providers: ["remote"],
provider: { remote: { npm: "@ai-sdk/openai-compatible", models: { chat: {} } } },
},
})
},
})
const origin = server.url.origin
const integrationID = Integration.ID.make(origin)
const credentialNode = makeGlobalNode({
service: Credential.Service,
layer: Layer.mock(Credential.Service)({
list: () =>
Effect.succeed(
key === undefined
? []
: [
new Credential.Info({
id: Credential.ID.create(),
integrationID,
label: "default",
value: Credential.Key.make({ type: "key", key }),
}),
],
),
}),
deps: [],
})
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const remote = (entries: readonly Entry[]) => ({
provider: Config.latest(entries, "providers")?.remote?.package,
policies: Config.latest(entries, "experimental")?.policies,
})
const loaded = {
provider: "aisdk:@ai-sdk/openai-compatible",
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "remote", effect: "allow" },
],
} as const
const removed = { provider: undefined, policies: undefined }
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
const kv = Layer.succeedContext(yield* Layer.build(LayerNode.compile(KV.node)))
// Each build is a fresh process sharing one persisted KV store.
const instance = () =>
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node, WellKnown.node]), [
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(project) })),
),
),
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
Credential.node.replace(credentialNode),
KV.node.replace(kv),
Watcher.node.replace(Watcher.testLayer),
])
const services = Effect.gen(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
// Each reload also rewrites a local file so Config publishes an update to wait on.
const reload = Effect.fnUntraced(function* (shell: string, trigger: Effect.Effect<unknown, Error>) {
const updated = yield* bus
.subscribe(Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell })))
yield* trigger
yield* Fiber.join(updated)
return remote(yield* config.entries())
})
const switched = bus.publish(
Credential.Event.Switched,
{ credentialID: Credential.ID.create(), integrationID },
{ global: true },
)
return { config, bus, wellknown: yield* WellKnown.Service, reload, switched }
})
yield* Effect.gen(function* () {
const warm = yield* services
expect(yield* warm.reload("initial", warm.wellknown.add(origin))).toEqual(loaded)
configStatus = 503
const before = requests
expect(yield* warm.reload("unavailable", warm.switched)).toEqual(loaded)
expect(requests).toBeGreaterThan(before)
key = undefined
expect(yield* warm.reload("logged-out", warm.switched)).toEqual(loaded)
manifestStatus = 503
const updated = yield* warm.bus
.subscribe(WellKnown.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
expect(yield* warm.wellknown.refresh()).toBe(false)
yield* Effect.sleep("50 millis")
expect(updated.pollUnsafe()).toBeUndefined()
}).pipe(Effect.scoped, Effect.provide(instance()))
key = "secret"
yield* Effect.gen(function* () {
const cold = yield* services
expect(remote(yield* cold.config.entries())).toEqual(loaded)
// The wellknown plugin loads these entries at startup and dies if they fail.
expect(yield* cold.wellknown.entries()).toHaveLength(1)
expect(yield* cold.reload("removed", cold.wellknown.remove(origin))).toEqual(removed)
manifestStatus = 200
expect(yield* cold.reload("re-added", cold.wellknown.add(origin))).toEqual(removed)
}).pipe(Effect.scoped, Effect.provide(instance()))
}).pipe(Effect.ensuring(Effect.promise(() => server.stop(true))))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("logs redacted source-aware diagnostics for every config source", () => {
const output: Array<Record<string, unknown>> = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
@@ -613,6 +754,7 @@ describe("Config", () => {
remove: () => Effect.die("unused Wellknown.remove"),
// Exercise the loader boundary against a malformed implementation response.
resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
cached: () => Effect.succeed([]),
}),
),
deps: [],
@@ -31,6 +31,7 @@ export const emptyWellknownNode = makeGlobalNode({
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
resolve: () => Effect.die("unused Wellknown.resolve"),
cached: () => Effect.succeed([]),
}),
),
deps: [],
@@ -277,7 +277,7 @@ describe("GoogleVertexPlugin", () => {
}),
)
it.effect("defaults location to us-central1 when only project is configured", () =>
it.effect("defaults location to global when only project is configured", () =>
withEnv(
{
GOOGLE_CLOUD_PROJECT: undefined,
@@ -299,7 +299,7 @@ describe("GoogleVertexPlugin", () => {
yield* addPlugin()
const provider = required(yield* catalog.get(Provider.ID.make("google-vertex")))
expect(provider.settings?.project).toBe("config-project")
expect(provider.settings?.location).toBe("us-central1")
expect(provider.settings?.location).toBe("global")
}),
),
)
+34 -1
View File
@@ -845,7 +845,16 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
return {
partialEvents,
completeEvents: [...partialEvents, LLMEvent.toolInputEnd({ id, name: "echo" })],
expectedAssistant: { type: "assistant", content: [expectedContent] },
expectedAssistant: {
type: "assistant",
content: [
{
type: "tool",
id,
state: { status: "error", input: {}, error: { type: "tool.input-incomplete" } },
},
],
},
expectedContent,
}
}
@@ -6023,6 +6032,30 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("settles unfinished tool input after an output limit", function* (s) {
yield* s.llm.push(
TestLLM.complete(
{ reason: { normalized: "length" } },
LLMEvent.toolInputStart({ id: "call-incomplete", name: "echo" }),
LLMEvent.toolInputDelta({ id: "call-incomplete", name: "echo", text: '{"text":"partial' }),
),
TestLLM.stop(),
)
yield* s.runPrompt("Recover unfinished tool input")
expect(s.requests).toHaveLength(2)
expect(s.executions).toEqual([])
expect(requireAssistant(yield* s.context).content).toMatchObject([
{
type: "tool",
id: "call-incomplete",
executed: false,
state: { status: "error", error: { type: "tool.input-incomplete" } },
},
])
})
scenario("continues after malformed local tool input without exposing raw arguments", function* (s) {
const marker = "raw-malformed-marker"
const raw = `{"text":"${marker}`
+8 -39
View File
@@ -110,15 +110,6 @@ describe("WriteTool", () => {
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write", "execute"])
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
const files = [
{
file: "src/new.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
]
expect(settled).toEqual({
status: "completed",
output: {
@@ -126,10 +117,8 @@ describe("WriteTool", () => {
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
resource: "src/new.txt",
existed: false,
files,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
metadata: { files },
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
@@ -156,7 +145,7 @@ describe("WriteTool", () => {
}),
)
it.live("formats the committed file and returns the diff for final formatted content", () =>
it.live("formats the committed file", () =>
withTempDir((tmp) => {
const fixture = makeWriteFixture()
const target = path.join(tmp.path, "formatted.txt")
@@ -165,33 +154,13 @@ describe("WriteTool", () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase())
return true
})
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, fixture, (registry) =>
executeTool(registry, call({ path: "formatted.txt", content: "format me\n" })),
),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
const files = [
{
file: "formatted.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringMatching(/-before\n\+FORMAT ME\n/),
},
]
expect(settled.output).toMatchObject({ files })
expect(settled.metadata).toMatchObject({ files })
expect(fixture.assertions[0]?.metadata).toMatchObject({
files: [{ patch: expect.stringContaining("+format me") }],
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME\n")
}),
),
return withTool(tmp.path, fixture, (registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
status: "completed",
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
}),
)
}),
)
+5 -2
View File
@@ -28,9 +28,12 @@ export type SessionUserAttachmentReference = {
export type SessionUserActions = {
openAttachment?: (file: PromptFileAttachment) => void
revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
/** A steer the server has not delivered yet. Like the TUI, it can move to the queue or be deleted. */
/**
* An input the server has not delivered yet. Like the TUI, a pending steer can move to the queue or be deleted;
* it shows as waiting only while `steering` behind delivered work or `stranded` by an execution that ended.
*/
pending?: {
steer: (messageID: string) => boolean
status: (messageID: string) => "starting" | "steering" | "stranded" | "queued" | undefined
queue: (input: { sessionID: string; messageID: string }) => Promise<void>
remove: (input: { sessionID: string; messageID: string }) => Promise<void>
}
@@ -17,7 +17,7 @@ export type Projection = {
}
// Split with the renderer's math syntax so a display block is never cut into separately rendered pieces.
const lexer = new Marked(...markdownMath)
const lexer = new Marked(markdownMath)
function refs(text: string) {
if (!text.includes("]:")) return false
@@ -259,7 +259,9 @@ export function CurrentUserMessageDisplay(props: {
const dialog = useDialog()
const i18n = useI18n()
const [state, setState] = createStore({ copied: false, reverting: false, updating: false })
const pending = createMemo(() => !!props.actions?.pending?.steer(props.message.id))
const status = createMemo(() => props.actions?.pending?.status(props.message.id))
const pending = createMemo(() => status() !== undefined && status() !== "queued")
const waiting = createMemo(() => status() === "steering" || status() === "stranded")
const attachments = createMemo(() => (props.message.files ?? []).filter(attached))
const references = createMemo(() => props.references ?? [])
const inlineFiles = createMemo(() => (props.message.files ?? []).filter((file) => !!file.mention))
@@ -271,7 +273,7 @@ export function CurrentUserMessageDisplay(props: {
const metaHead = createMemo(() => {
const agent = props.agent
return [pending() ? i18n.t("ui.message.pending") : "", agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()]
return [waiting() ? i18n.t("ui.message.pending") : "", agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()]
.filter(Boolean)
.join("\u00A0\u00B7\u00A0")
})
@@ -353,7 +355,7 @@ export function CurrentUserMessageDisplay(props: {
return (
<div
data-component="user-message"
data-pending={pending() ? "true" : undefined}
data-pending={waiting() ? "true" : undefined}
data-timeline-part-id={props.text ? `${props.message.id}:text:0` : undefined}
>
<Show
-1
View File
@@ -117,7 +117,6 @@
"fuzzysort": "catalog:",
"katex": "0.16.47",
"marked": "catalog:",
"marked-katex-extension": "5.1.13",
"marked-shiki": "catalog:",
"motion": "12.34.5",
"remeda": "catalog:",
+41 -16
View File
@@ -15,16 +15,35 @@ test("renders inline and block math", async () => {
expect(await parser.parse("$$\nx^2\n$$\n")).toContain('<span class="katex-display">')
})
test.each(["Energy is $E = mc^2$.", "因此 $x^2$ 是正数", "$a_1$ and $b_1$."])(
"renders dollar inline math: %s",
async (text) => {
const html = await parser.parse(text)
expect(html).toContain('<span class="katex">')
expect(html).not.toContain("$")
},
)
test.each([
"Energy is $E = mc^2$.",
"因此 $x^2$ 是正数",
"因此$x^2$是正数",
"$a_1$ and $b_1$.",
"where (i.e. $x$) holds",
"the $x$-axis",
"is $x$; y",
"$x$'s value",
"ack ($440\\text{ ms}$ IPC p50)",
])("renders dollar inline math: %s", async (text) => {
const html = await parser.parse(text)
expect(html).toContain('<span class="katex">')
expect(html).not.toContain("$")
})
test.each(["$$E = mc^2$$", "Inline $$x$$ display", "$$\na\n\nb\n$$\n"])(
test("renders every dollar span in a line", async () => {
const html = await parser.parse("ack ($440\\text{ ms}$ IPC p50, $413\\text{ MB} \\rightarrow 98\\text{ MB}$)")
expect(html.match(/<span class="katex">/g)).toHaveLength(2)
expect(html).not.toContain("$")
})
test("keeps an escaped dollar inside dollar math", async () => {
const html = await parser.parse("Escaped $a\\$b$ dollar")
expect(html.match(/<span class="katex">/g)).toHaveLength(1)
expect(html).toContain('<annotation encoding="application/x-tex">a\\$b</annotation>')
})
test.each(["$$E = mc^2$$", "Inline $$x$$ display", "($$x$$)", "$$\na\n\nb\n$$\n"])(
"renders dollar display math: %s",
async (text) => {
const html = await parser.parse(text)
@@ -37,19 +56,25 @@ test.each([
"It costs $5 and $10.",
"From $5-$10 a month",
"It costs $5, originally $10, ranging from $1,000 to $2,000.",
"It costs $5 (or $6) now",
"between $5 and $6 dollars",
"paid $5/$6.",
"Use $HOME and $PATH",
"Use $HOME/$USER or $PATH:$HOME",
"Use `$x$` as a placeholder",
"An escaped \\$x$ dollar",
"$ x $ has space inside its delimiters",
])("leaves prices, shell variables, and code as text: %s", async (text) => {
expect(await parser.parse(text)).not.toContain("katex")
})
// marked-katex-extension requires a space or line start before `$` and a space or punctuation after the closing `$`.
test.each(["where (i.e. $x$) holds", "the $x$-axis", "因此$x^2$是正数"])(
"leaves tight dollar math as text: %s",
async (text) => {
expect(await parser.parse(text)).not.toContain("katex")
},
)
// Each repeated unit took seconds at this size when `start` retried the dollar regex from every `$` for every text token.
test("lexes dollar-heavy paragraphs in near-linear time", () => {
const units = ["`a` $5 ", "$a ", "\\$$a", "$a\\\\\\ ", "It costs $5, ", "with `c` $x_1$ and "]
const startedAt = performance.now()
units.forEach((unit) => parser.lexer(unit.repeat(Math.ceil(10_000 / unit.length))))
expect(performance.now() - startedAt).toBeLessThan(500)
})
test("uses the configured code highlighter", async () => {
expect(await parser.parse("```ts\nconst value = 1\n```\n")).toBe('<pre data-language="ts">const value = 1</pre>\n')
+82 -30
View File
@@ -1,45 +1,97 @@
import katex from "katex"
import type { MarkedExtension, Tokens } from "marked"
import markedKatex from "marked-katex-extension"
import markedShiki from "marked-shiki"
import { createMarkdownBase } from "./marked-base"
export function createMarkdownParser(highlight: (code: string, language: string) => string | Promise<string>) {
return createMarkdownBase().use(...markdownMath, markedShiki({ highlight }))
return createMarkdownBase().use(markdownMath, markedShiki({ highlight }))
}
// `$$...$$` renders as display math anywhere in a line.
// `$...$` follows Pandoc's boundaries so prices and shell variables stay text: the opening `$` needs a non-space
// after it, the closing `$` needs a non-space before it and no letter, digit, or underscore after it.
// Neither form spans a newline, and `$...$` cannot contain an unescaped `$`, so a failed `$...$` attempt stops at
// the next `$` and a failed `$$...$$` attempt stops at the end of the line.
const inlineDollarMathRegex =
/\$\$(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\$\$|\$(?=[^\s$])((?:\\.|[^\\\n$])*?(?:\\\S|[^\s\\$]))\$(?!\w)/y
const inlineParenMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/
// marked-katex-extension handles `$...$`, `$$...$$`, and `$$` fenced blocks; it has no `\(...\)` syntax.
export const markdownMath: MarkedExtension[] = [
markedKatex({ throwOnError: false }),
{
extensions: [
{
name: "inlineParenKatex",
level: "inline",
start(src) {
const index = src.indexOf("\\(")
const blockMathRegex = /^(\${1,2})\n((?:\\[^]|[^\\])+?)\n\1(?:\n|$)/
if (index === -1) return
function renderMath(token: Tokens.Generic) {
return katex.renderToString(token.text, { throwOnError: false, displayMode: token.displayMode })
}
return index
},
tokenizer(src) {
const match = src.match(inlineParenMathRegex)
export const markdownMath: MarkedExtension = {
extensions: [
{
name: "inlineDollarKatex",
level: "inline",
// marked calls `start` again for every text token with the rest of the paragraph, so it must not try the
// regex at each `$`; a `$` that fails the tokenizer just stays in the merged text.
start(src) {
const index = src.indexOf("$")
if (!match) return
if (index === -1) return
return {
type: "inlineParenKatex",
raw: match[0],
text: match[1].trim(),
}
},
renderer(token: Tokens.Generic) {
return katex.renderToString(token.text, { throwOnError: false })
},
return index
},
],
},
]
tokenizer(src) {
inlineDollarMathRegex.lastIndex = 0
const match = inlineDollarMathRegex.exec(src)
if (!match) return
return {
type: "inlineDollarKatex",
raw: match[0],
text: (match[1] ?? match[2]).trim(),
displayMode: match[1] !== undefined,
}
},
renderer: renderMath,
},
{
name: "inlineParenKatex",
level: "inline",
start(src) {
const index = src.indexOf("\\(")
if (index === -1) return
return index
},
tokenizer(src) {
const match = src.match(inlineParenMathRegex)
if (!match) return
return {
type: "inlineParenKatex",
raw: match[0],
text: match[1].trim(),
displayMode: false,
}
},
renderer: renderMath,
},
{
name: "blockKatex",
level: "block",
tokenizer(src) {
const match = src.match(blockMathRegex)
if (!match) return
return {
type: "blockKatex",
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2,
}
},
renderer: (token) => renderMath(token) + "\n",
},
],
}