mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-08 08:18:32 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
434f7b2a36 | ||
|
|
93afa91904 | ||
|
|
d869df3801 | ||
|
|
9e100f4f88 | ||
|
|
a19285d10d | ||
|
|
f108fa4928 | ||
|
|
a80ca68c96 | ||
|
|
c9e1ed7080 |
No files matched your search
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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", () => [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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] : []
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
Reference in new issue
Block a user