mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 22:46:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
917dcd7004 |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
"@opencode-ai/server": patch
|
||||
---
|
||||
|
||||
Keep the live models.dev catalog independent of persistence so failed cache reads or writes cannot prevent model updates. Cache downloaded catalogs in local files on Bun and Node, and use the bundled snapshot plus in-memory refreshes on workerd instead of storing the catalog in each Durable Object's database. Explicit catalog files refresh locally without fetching or writing an implicit cache.
|
||||
@@ -553,20 +553,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode-ai/latex",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode-ai/merman",
|
||||
"version": "0.0.0",
|
||||
@@ -745,7 +731,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@playwright/test": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
@@ -851,7 +836,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@playwright/test": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@storybook/addon-a11y": "10.4.4",
|
||||
"@storybook/addon-docs": "10.4.4",
|
||||
@@ -893,7 +877,6 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/latex": "workspace:*",
|
||||
"@opencode-ai/merman": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
@@ -2165,8 +2148,6 @@
|
||||
|
||||
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||
|
||||
"@opencode-ai/latex": ["@opencode-ai/latex@workspace:packages/latex"],
|
||||
|
||||
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-XHUy+Hk+RHUHREt4x0HfSzr3RlEvgBd4H/fV0rlXw2M=",
|
||||
"aarch64-linux": "sha256-/gIaM62uV2X6KCnkSi6QjyT7t7uJ2L7u7CxXxPQYK+w=",
|
||||
"aarch64-darwin": "sha256-PWG6ALh6kG7mnC6AzEuIAU54BEQ4IB+SyyQFGb/s+Dc=",
|
||||
"x86_64-darwin": "sha256-TcgRDHG4CAT+XoCi0JNansONjg06ZPRmeLRsqmdJ4B4="
|
||||
"x86_64-linux": "sha256-QWLIdvu985FH5I9cZJOAuoeFeXU+4Jx9RzBB9RPoeeQ=",
|
||||
"aarch64-linux": "sha256-SSzGD5hMj2vFvyw+dUPR9g/ZH6qhs0ZyZ/DnltZt3N8=",
|
||||
"aarch64-darwin": "sha256-CeFUxiV+e8pKho+YcSclC3soQBogoxNMxwyIMztAExU=",
|
||||
"x86_64-darwin": "sha256-FYwcACzU72y0+KtOpFfU7ndak8vMasqMgd5NLS6+XtY="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +406,6 @@ const AnthropicEvent = Schema.Struct({
|
||||
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
readonly usage?: Usage
|
||||
@@ -441,18 +440,18 @@ const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefin
|
||||
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
|
||||
}
|
||||
|
||||
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
|
||||
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
|
||||
|
||||
const signatureFromMetadata = (metadata: ProviderMetadata | undefined, key: string): string | undefined => {
|
||||
const provider = metadata?.[key]
|
||||
if (!ProviderShared.isRecord(provider)) return undefined
|
||||
return typeof provider.signature === "string" ? provider.signature : undefined
|
||||
const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
|
||||
const anthropic = metadata?.anthropic
|
||||
if (!ProviderShared.isRecord(anthropic)) return undefined
|
||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||
}
|
||||
|
||||
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined, key: string): string | undefined => {
|
||||
const provider = metadata?.[key]
|
||||
if (!ProviderShared.isRecord(provider)) return undefined
|
||||
return typeof provider.redactedData === "string" ? provider.redactedData : undefined
|
||||
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
|
||||
const anthropic = metadata?.anthropic
|
||||
if (!ProviderShared.isRecord(anthropic)) return undefined
|
||||
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
||||
@@ -512,16 +511,13 @@ const serverToolResultType = (name: string): AnthropicServerToolResultType | und
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (
|
||||
part: ToolResultPart,
|
||||
providerMetadataKey: string,
|
||||
) {
|
||||
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (part: ToolResultPart) {
|
||||
const wireType = serverToolResultType(part.name)
|
||||
if (!wireType)
|
||||
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
|
||||
// Prefer the provider-owned replay payload; fall back to the result value for
|
||||
// histories constructed directly from provider events.
|
||||
const payload = part.providerMetadata?.[providerMetadataKey]?.["result"] ?? part.result.value
|
||||
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
|
||||
return {
|
||||
type: wireType,
|
||||
tool_use_id: scrubToolCallID(part.id),
|
||||
@@ -808,7 +804,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
breakpoints: Cache.Breakpoints,
|
||||
) {
|
||||
const messages: AnthropicMessage[] = []
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
|
||||
for (const [index, message] of request.messages.entries()) {
|
||||
if (message.role === "system") {
|
||||
@@ -854,8 +849,8 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
if (part.type === "reasoning") {
|
||||
// A signature marks visible thinking; only signature-less parts carrying
|
||||
// redactedData round-trip as opaque redacted_thinking blocks.
|
||||
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata, providerMetadataKey)
|
||||
const redactedData = redactedDataFromMetadata(part.providerMetadata, providerMetadataKey)
|
||||
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
|
||||
const redactedData = redactedDataFromMetadata(part.providerMetadata)
|
||||
if (signature === undefined && redactedData !== undefined) {
|
||||
content.push({ type: "redacted_thinking", data: redactedData })
|
||||
continue
|
||||
@@ -884,7 +879,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted) {
|
||||
content.push(yield* lowerServerToolResult(part, providerMetadataKey))
|
||||
content.push(yield* lowerServerToolResult(part))
|
||||
continue
|
||||
}
|
||||
return yield* invalid(
|
||||
@@ -1074,7 +1069,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// inclusive `inputTokens` the rest of the contract expects. Extended
|
||||
// thinking tokens are included in `output_tokens`; newer responses also
|
||||
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
|
||||
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const nonCached = usage.input_tokens ?? undefined
|
||||
const cacheRead = usage.cache_read_input_tokens ?? undefined
|
||||
@@ -1088,7 +1083,7 @@ const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
providerMetadata: { anthropic: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1097,7 +1092,7 @@ const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string
|
||||
// field prefers `right` when defined, falls back to `left`. `inputTokens` is
|
||||
// recomputed from the merged breakdown so the inclusive total stays
|
||||
// consistent with `nonCached + cacheRead + cacheWrite`.
|
||||
const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerMetadataKey: string) => {
|
||||
const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
||||
if (!left) return right
|
||||
if (!right) return left
|
||||
const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens
|
||||
@@ -1115,9 +1110,7 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerM
|
||||
reasoningTokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
providerMetadata: {
|
||||
[providerMetadataKey]:
|
||||
mergeJsonRecords(left.providerMetadata?.[providerMetadataKey], right.providerMetadata?.[providerMetadataKey]) ??
|
||||
{},
|
||||
anthropic: mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1135,7 +1128,7 @@ const SERVER_TOOL_RESULT_NAMES: Record<AnthropicServerToolResultType, string> =
|
||||
|
||||
const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES
|
||||
|
||||
const serverToolResultEvent = (block: AnthropicStreamBlock, providerMetadataKey: string): LLMEvent | undefined => {
|
||||
const serverToolResultEvent = (block: AnthropicStreamBlock): LLMEvent | undefined => {
|
||||
if (!block.type || !isServerToolResultType(block.type)) return undefined
|
||||
const errorPayload =
|
||||
typeof block.content === "object" && block.content !== null && "type" in block.content
|
||||
@@ -1149,7 +1142,7 @@ const serverToolResultEvent = (block: AnthropicStreamBlock, providerMetadataKey:
|
||||
providerExecuted: true,
|
||||
// The complete payload is irreducible provider replay state: subsequent
|
||||
// stateless requests must round-trip the typed result block verbatim.
|
||||
providerMetadata: providerMetadata(providerMetadataKey, { blockType: block.type, result: block.content }),
|
||||
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1158,8 +1151,8 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
const NO_EVENTS: StepResult["1"] = []
|
||||
|
||||
const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult => {
|
||||
const usage = mapUsage(event.message?.usage, state.providerMetadataKey)
|
||||
return [usage ? { ...state, usage: mergeUsage(state.usage, usage, state.providerMetadataKey) } : state, NO_EVENTS]
|
||||
const usage = mapUsage(event.message?.usage)
|
||||
return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS]
|
||||
}
|
||||
|
||||
const onContentBlockStart = (
|
||||
@@ -1211,16 +1204,14 @@ const onContentBlockStart = (
|
||||
if (block.type === "thinking" && block.thinking !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
const id = `reasoning-${event.index ?? 0}`
|
||||
const metadata =
|
||||
block.signature === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { signature: block.signature })
|
||||
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, metadata)
|
||||
const providerMetadata =
|
||||
block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
|
||||
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: block.thinking
|
||||
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, metadata)
|
||||
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
|
||||
: lifecycle,
|
||||
reasoningSignatures:
|
||||
event.index === undefined || block.signature === undefined
|
||||
@@ -1243,14 +1234,14 @@ const onContentBlockStart = (
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${event.index ?? 0}`,
|
||||
providerMetadata(state.providerMetadataKey, { redactedData: block.data }),
|
||||
anthropicMetadata({ redactedData: block.data }),
|
||||
),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const result = serverToolResultEvent(block, state.providerMetadataKey)
|
||||
const result = serverToolResultEvent(block)
|
||||
if (!result) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]]
|
||||
@@ -1330,7 +1321,7 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
||||
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
|
||||
events,
|
||||
`reasoning-${event.index}`,
|
||||
signature === undefined ? undefined : providerMetadata(state.providerMetadataKey, { signature }),
|
||||
signature === undefined ? undefined : anthropicMetadata({ signature }),
|
||||
)
|
||||
events.push(...resultEvents)
|
||||
const reasoningSignatures = { ...state.reasoningSignatures }
|
||||
@@ -1342,7 +1333,7 @@ const onMessageDelta = (
|
||||
state: ParserState,
|
||||
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
|
||||
): StepResult => {
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1355,7 +1346,7 @@ const onMessageDelta = (
|
||||
providerMetadata:
|
||||
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
|
||||
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
|
||||
},
|
||||
},
|
||||
NO_EVENTS,
|
||||
@@ -1481,8 +1472,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(AnthropicEvent),
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
initial: () => ({
|
||||
tools: ToolStream.empty<number>(),
|
||||
reasoningSignatures: {},
|
||||
lifecycle: Lifecycle.initial(),
|
||||
|
||||
@@ -258,21 +258,19 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
tool: (name) => ({ tool: { name } }) as const,
|
||||
})
|
||||
|
||||
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
|
||||
const bedrockMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ bedrock: metadata })
|
||||
|
||||
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const reasoningSignature = (part: ReasoningPart) => {
|
||||
const bedrock = part.providerMetadata?.bedrock
|
||||
return (
|
||||
part.encrypted ??
|
||||
(ProviderShared.isRecord(metadata) && typeof metadata.signature === "string" ? metadata.signature : undefined)
|
||||
(ProviderShared.isRecord(bedrock) && typeof bedrock.signature === "string" ? bedrock.signature : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string"
|
||||
? metadata.redactedData
|
||||
: undefined
|
||||
const reasoningRedactedData = (part: ReasoningPart) => {
|
||||
const bedrock = part.providerMetadata?.bedrock
|
||||
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string" ? bedrock.redactedData : undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
@@ -320,7 +318,6 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
) {
|
||||
const messages: BedrockMessage[] = []
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "system") {
|
||||
@@ -368,8 +365,8 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
const signature = reasoningSignature(part, providerMetadataKey)
|
||||
const redactedData = reasoningRedactedData(part, providerMetadataKey)
|
||||
const signature = reasoningSignature(part)
|
||||
const redactedData = reasoningRedactedData(part)
|
||||
if (signature === undefined && redactedData !== undefined) {
|
||||
content.push({ reasoningContent: { redactedContent: redactedData } })
|
||||
continue
|
||||
@@ -469,7 +466,7 @@ const mapFinishReason = (reason: string): FinishReason => {
|
||||
|
||||
// AWS reports inputTokens separately from cache reads and writes.
|
||||
// Bedrock does not break reasoning out of outputTokens for current models.
|
||||
const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: string): Usage | undefined => {
|
||||
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const inputTokens = ProviderShared.sumTokens(
|
||||
usage.inputTokens,
|
||||
@@ -483,12 +480,11 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
|
||||
cacheReadInputTokens: usage.cacheReadInputTokens,
|
||||
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
providerMetadata: { bedrock: usage },
|
||||
})
|
||||
}
|
||||
|
||||
interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
||||
@@ -545,14 +541,20 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const reasoning = event.contentBlockDelta.delta.reasoningContent
|
||||
const events: LLMEvent[] = []
|
||||
const redactedData = reasoning.redactedContent ?? reasoning.data
|
||||
const metadata = reasoning.signature
|
||||
? providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
|
||||
const providerMetadata = reasoning.signature
|
||||
? bedrockMetadata({ signature: reasoning.signature })
|
||||
: redactedData !== undefined
|
||||
? providerMetadata(state.providerMetadataKey, { redactedData })
|
||||
? bedrockMetadata({ redactedData })
|
||||
: undefined
|
||||
const lifecycle =
|
||||
reasoning.text !== undefined || metadata !== undefined
|
||||
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
|
||||
reasoning.text !== undefined || providerMetadata !== undefined
|
||||
? Lifecycle.reasoningDelta(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
reasoning.text ?? "",
|
||||
providerMetadata,
|
||||
)
|
||||
: state.lifecycle
|
||||
return [
|
||||
{
|
||||
@@ -594,7 +596,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
state.reasoningSignatures[index]
|
||||
? providerMetadata(state.providerMetadataKey, { signature: state.reasoningSignatures[index] })
|
||||
? bedrockMetadata({ signature: state.reasoningSignatures[index] })
|
||||
: undefined,
|
||||
)
|
||||
events.push(...resultEvents)
|
||||
@@ -631,7 +633,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.metadata) {
|
||||
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
|
||||
const usage = mapUsage(event.metadata.usage) ?? state.pendingFinish?.usage
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -696,8 +698,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: BedrockEvent,
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
initial: () => ({
|
||||
tools: ToolStream.empty<number>(),
|
||||
pendingFinish: undefined,
|
||||
hasToolCalls: false,
|
||||
|
||||
@@ -229,7 +229,6 @@ type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly route: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly finishReason?: string
|
||||
readonly hasToolCalls: boolean
|
||||
readonly promptFeedback?: GeminiPromptFeedback
|
||||
@@ -286,23 +285,22 @@ const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPar
|
||||
return { inlineData: { mimeType: media.mime, data: media.base64 } }
|
||||
})
|
||||
|
||||
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
|
||||
const googleMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ google: metadata })
|
||||
|
||||
const thoughtSignature = (metadata: ProviderMetadata | undefined, key: string) => {
|
||||
const value = metadata?.[key]
|
||||
return ProviderShared.isRecord(value) && typeof value.thoughtSignature === "string"
|
||||
? value.thoughtSignature
|
||||
const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
const google = providerMetadata?.google
|
||||
return ProviderShared.isRecord(google) && typeof google.thoughtSignature === "string"
|
||||
? google.thoughtSignature
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, omitIds: boolean, metadataKey: string) => ({
|
||||
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
|
||||
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata),
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
||||
const contents: GeminiContent[] = []
|
||||
const metadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
const omitCallIds = omitsFunctionCallIds(request.model.id)
|
||||
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
|
||||
let pendingMedia: GeminiInlineDataPart[] | undefined
|
||||
@@ -344,19 +342,15 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
||||
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
||||
if (part.type === "text") {
|
||||
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey) })
|
||||
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
parts.push({
|
||||
text: part.text,
|
||||
thought: true,
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
|
||||
})
|
||||
parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
const lowered = lowerToolCall(part, omitCallIds, metadataKey)
|
||||
const lowered = lowerToolCall(part, omitCallIds)
|
||||
const signature = lowered.thoughtSignature
|
||||
parts.push({
|
||||
...lowered,
|
||||
@@ -504,7 +498,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
|
||||
// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
|
||||
// to produce the inclusive `outputTokens` the rest of the contract expects.
|
||||
const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
|
||||
const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||
if (!usage) return undefined
|
||||
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
|
||||
// token arithmetic below treats them like absent counts.
|
||||
@@ -525,7 +519,7 @@ const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: thoughts,
|
||||
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
|
||||
providerMetadata: providerMetadata(metadataKey, usage),
|
||||
providerMetadata: { google: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -573,14 +567,14 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
if (state.textSignature !== undefined)
|
||||
lifecycle = Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
|
||||
googleMetadata({ thoughtSignature: state.textSignature }),
|
||||
)
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
@@ -590,9 +584,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
},
|
||||
usage: state.usage,
|
||||
providerMetadata:
|
||||
state.promptFeedback === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { promptFeedback: state.promptFeedback }),
|
||||
state.promptFeedback === undefined ? undefined : googleMetadata({ promptFeedback: state.promptFeedback }),
|
||||
})
|
||||
return events
|
||||
}
|
||||
@@ -601,9 +593,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
const nextState = {
|
||||
...state,
|
||||
promptFeedback: event.promptFeedback ?? state.promptFeedback,
|
||||
usage: event.usageMetadata
|
||||
? (mapUsage(event.usageMetadata, state.providerMetadataKey) ?? state.usage)
|
||||
: state.usage,
|
||||
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
|
||||
}
|
||||
const candidate = event.candidates?.[0]
|
||||
if (!candidate?.content)
|
||||
@@ -647,7 +637,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
events,
|
||||
"reasoning-0",
|
||||
part.text,
|
||||
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
|
||||
signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -655,16 +645,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
part.text,
|
||||
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
|
||||
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
|
||||
)
|
||||
textSignature = undefined
|
||||
continue
|
||||
@@ -684,9 +672,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||
)
|
||||
lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||
events.push(
|
||||
@@ -695,7 +681,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
name: part.functionCall.name,
|
||||
input,
|
||||
providerMetadata: part.thoughtSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: part.thoughtSignature })
|
||||
? googleMetadata({ thoughtSignature: part.thoughtSignature })
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
@@ -734,7 +720,6 @@ export const protocol = Protocol.make({
|
||||
event: Protocol.jsonEvent(GeminiEvent),
|
||||
initial: (request) => ({
|
||||
route: `${request.model.provider}/${request.model.route.id}`,
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
hasToolCalls: false,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
}),
|
||||
|
||||
@@ -253,7 +253,6 @@ interface PendingToolDelta {
|
||||
}
|
||||
|
||||
export interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
|
||||
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
||||
@@ -325,18 +324,17 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
|
||||
const openAICompatibleReasoningContent = (native: unknown) =>
|
||||
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
||||
|
||||
const reasoningField = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const field = part.providerMetadata?.[providerMetadataKey]?.reasoningField
|
||||
const reasoningField = (part: ReasoningPart) => {
|
||||
const field = part.providerMetadata?.openai?.reasoningField
|
||||
return typeof field === "string" ? field : undefined
|
||||
}
|
||||
|
||||
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown, providerMetadataKey: string) => {
|
||||
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
|
||||
const observed = parts.flatMap((part) => {
|
||||
const details = part.providerMetadata?.[providerMetadataKey]?.reasoningDetails
|
||||
const details = part.providerMetadata?.openai?.reasoningDetails
|
||||
return Array.isArray(details) ? details : []
|
||||
})
|
||||
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
|
||||
return observed
|
||||
if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
|
||||
}
|
||||
|
||||
@@ -368,7 +366,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
message: OpenAIChatRequestMessage,
|
||||
configuredField: string | undefined,
|
||||
requireReasoning: boolean,
|
||||
options: LoweringOptions & { readonly providerMetadataKey: string },
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const content: TextPart[] = []
|
||||
const reasoning: ReasoningPart[] = []
|
||||
@@ -390,14 +388,10 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
}
|
||||
}
|
||||
const text = reasoning.map((part) => part.text).join("")
|
||||
const details = reasoningDetails(reasoning, message.native?.openaiCompatible, options.providerMetadataKey)
|
||||
const observedField = reasoning
|
||||
.map((part) => reasoningField(part, options.providerMetadataKey))
|
||||
.find((value) => value !== undefined)
|
||||
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
|
||||
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
|
||||
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
||||
const fullyStructured = reasoning.every((part) =>
|
||||
Array.isArray(part.providerMetadata?.[options.providerMetadataKey]?.reasoningDetails),
|
||||
)
|
||||
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
|
||||
const field = (() => {
|
||||
if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined))
|
||||
return configuredField
|
||||
@@ -465,7 +459,7 @@ const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
reasoningField: string | undefined,
|
||||
requireReasoning: boolean,
|
||||
options: LoweringOptions & { readonly providerMetadataKey: string },
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
|
||||
if (message.role === "assistant")
|
||||
@@ -501,7 +495,6 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
|
||||
const lowering = {
|
||||
...options,
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
toolCallID: (id: string) => {
|
||||
if (mistral)
|
||||
return id
|
||||
@@ -827,7 +820,7 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
|
||||
// Providers differ on cache-hit location: OpenAI uses
|
||||
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
|
||||
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string): Usage | undefined => {
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
@@ -846,7 +839,7 @@ const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string):
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
providerMetadata: { openai: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -920,12 +913,8 @@ const conflictingReasoningTextDetails = (previous: Record<string, unknown>, curr
|
||||
const conflictingDetailValue = (previous: unknown, current: unknown) =>
|
||||
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
|
||||
|
||||
const reasoningMetadata = (
|
||||
providerMetadataKey: string,
|
||||
field: ParserState["reasoningField"],
|
||||
details?: ReadonlyArray<unknown>,
|
||||
) => ({
|
||||
[providerMetadataKey]: {
|
||||
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
|
||||
openai: {
|
||||
...(field ? { reasoningField: field } : {}),
|
||||
...(details ? { reasoningDetails: details } : {}),
|
||||
},
|
||||
@@ -952,10 +941,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
|
||||
// `choice.usage` instead of the top-level `usage` field.
|
||||
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
|
||||
const usage =
|
||||
mapUsage(event.usage, state.providerMetadataKey) ??
|
||||
(choiceUsage ? mapUsage(choiceUsage, state.providerMetadataKey) : undefined) ??
|
||||
state.usage
|
||||
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
|
||||
const rawFinishReason = choice?.finish_reason
|
||||
const finishReason = rawFinishReason
|
||||
? {
|
||||
@@ -993,7 +979,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
||||
const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField)
|
||||
const deltaMetadata = reasoningMetadata(reasoningField)
|
||||
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
||||
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||
else if (
|
||||
@@ -1009,11 +995,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
}
|
||||
@@ -1023,11 +1005,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
}
|
||||
@@ -1088,7 +1066,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
|
||||
return [
|
||||
{
|
||||
providerMetadataKey: state.providerMetadataKey,
|
||||
tools: finished?.tools ?? tools,
|
||||
pendingTools,
|
||||
toolCallEvents: finished?.events ?? state.toolCallEvents,
|
||||
@@ -1132,18 +1109,12 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
|
||||
}
|
||||
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
|
||||
const metadata = reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
)
|
||||
const started =
|
||||
state.reasoningDetailsObserved && !state.reasoningEmitted
|
||||
? Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(state.providerMetadataKey, state.reasoningField),
|
||||
)
|
||||
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
|
||||
: state.lifecycle
|
||||
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
|
||||
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
|
||||
@@ -1170,7 +1141,6 @@ export const protocol = Protocol.make({
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
pendingTools: {},
|
||||
toolCallEvents: [],
|
||||
|
||||
@@ -23,14 +23,13 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "bedrock-mantle-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
providerMetadataKey: OpenAIResponses.route.providerMetadataKey,
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: OpenAIResponses.route.auth,
|
||||
@@ -41,7 +40,6 @@ const responsesRoute = Route.make({
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
id: "bedrock-mantle-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
})
|
||||
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
@@ -96,7 +94,6 @@ const config = (settings: Settings): Config => {
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
|
||||
@@ -35,7 +35,6 @@ const configuredRoute = (input: Config) => {
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
providerMetadataKey: "bedrock",
|
||||
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
|
||||
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
|
||||
})
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
const route = OpenAICompatibleChat.route.with({
|
||||
id: "google-vertex-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
const route = OpenAICompatibleResponses.route.with({
|
||||
id: "google-vertex-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
providerOptions: { store: false },
|
||||
})
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ const protocol = {
|
||||
const route = Route.make({
|
||||
id: "google-vertex-gemini",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
providerMetadataKey: "google",
|
||||
protocol,
|
||||
endpoint: Endpoint.path(({ request }) => {
|
||||
const model = String(request.model.id)
|
||||
|
||||
@@ -164,7 +164,6 @@ const bodyOptions = (input: unknown) => {
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: profile.provider,
|
||||
providerMetadataKey: "openrouter",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
|
||||
framing: Framing.sse,
|
||||
|
||||
@@ -89,7 +89,6 @@ export interface RouteDefaultsInput {
|
||||
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
readonly id?: string
|
||||
readonly provider?: string | ProviderID
|
||||
readonly providerMetadataKey?: string
|
||||
readonly auth?: Auth.Definition
|
||||
readonly transport?: Transport<Body, Prepared, unknown>
|
||||
readonly endpoint?: EndpointPatch<Body>
|
||||
@@ -290,16 +289,11 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
with: (patch: RoutePatch<Body, Prepared>) => {
|
||||
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
const { id, provider, auth, transport, endpoint, ...defaults } = patch
|
||||
return build({
|
||||
...routeInput,
|
||||
id: id ?? routeInput.id,
|
||||
provider: provider ?? routeInput.provider,
|
||||
providerMetadataKey:
|
||||
providerMetadataKey ??
|
||||
(provider !== undefined && String(provider) !== String(routeInput.provider)
|
||||
? String(provider)
|
||||
: routeInput.providerMetadataKey),
|
||||
auth: auth ?? routeInput.auth,
|
||||
endpoint: endpoint ? Endpoint.merge(routeInput.endpoint, endpoint) : routeInput.endpoint,
|
||||
transport: (transport as Transport<Body, Prepared, Frame> | undefined) ?? routeInput.transport,
|
||||
|
||||
@@ -40,6 +40,17 @@ const headerDetails = (headers: Headers.Headers) =>
|
||||
const normalizedHeaders = (headers: Headers.Headers) =>
|
||||
Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
|
||||
|
||||
const requestId = (headers: Record<string, string>) => {
|
||||
return (
|
||||
headers["x-request-id"] ??
|
||||
headers["request-id"] ??
|
||||
headers["x-amzn-requestid"] ??
|
||||
headers["x-amz-request-id"] ??
|
||||
headers["x-goog-request-id"] ??
|
||||
headers["cf-ray"]
|
||||
)
|
||||
}
|
||||
|
||||
const retryAfterMs = (headers: Record<string, string>) => {
|
||||
const millis = Number(headers["retry-after-ms"])
|
||||
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||
@@ -136,12 +147,14 @@ const responseHttp = (input: {
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly response: HttpClientResponse.HttpClientResponse
|
||||
readonly body: ReturnType<typeof responseBody>
|
||||
readonly requestId?: string | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
}) =>
|
||||
new HttpContext({
|
||||
request: requestDetails(input.request),
|
||||
response: responseDetails(input.response),
|
||||
...input.body,
|
||||
requestId: input.requestId,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
|
||||
@@ -166,6 +179,7 @@ const statusError =
|
||||
request,
|
||||
response,
|
||||
body: details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
}),
|
||||
}),
|
||||
@@ -202,6 +216,7 @@ export const classifyHttpFailure = (input: {
|
||||
? undefined
|
||||
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
|
||||
...details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
|
||||
response: Schema.optional(HttpResponseDetails),
|
||||
body: Schema.optional(Schema.String),
|
||||
bodyTruncated: Schema.optional(Schema.Boolean),
|
||||
requestId: Schema.optional(Schema.String),
|
||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -327,6 +327,7 @@ describe("RequestExecutor", () => {
|
||||
retryAfterMs: 0,
|
||||
rateLimit: { retryAfterMs: 0 },
|
||||
http: {
|
||||
requestId: "req_123",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { CacheHint, LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src/index.js"
|
||||
import { Auth, Endpoint, LLMClient, Route } from "../../src/route.js"
|
||||
import { Auth, LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
|
||||
import { GoogleVertexMessages } from "../../src/providers.js"
|
||||
@@ -810,99 +810,6 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips compatible provider metadata in its own namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = Route.make({
|
||||
id: "custom-anthropic-messages",
|
||||
provider: "custom-anthropic",
|
||||
protocol: AnthropicMessages.protocol,
|
||||
endpoint: Endpoint.path("/messages", { baseURL: "https://compatible.test/v1" }),
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
framing: AnthropicMessages.framing,
|
||||
}).model({ id: "custom-model" })
|
||||
const result = [
|
||||
{
|
||||
type: "web_search_result",
|
||||
url: "https://example.com",
|
||||
citations: [{ type: "web_search_result_location", cited_text: "Example" }],
|
||||
},
|
||||
]
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: compatible, prompt: "Search." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5, custom_start: true } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "Thinking." } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "custom_sig" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: { type: "redacted_thinking", data: "custom_redacted" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 2,
|
||||
content_block: {
|
||||
type: "server_tool_use",
|
||||
id: "custom_tool",
|
||||
name: "web_search",
|
||||
input: { query: "example" },
|
||||
},
|
||||
},
|
||||
{ type: "content_block_stop", index: 2 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 3,
|
||||
content_block: { type: "web_search_tool_result", tool_use_id: "custom_tool", content: result },
|
||||
},
|
||||
{ type: "content_block_stop", index: 3 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: "custom_stop" },
|
||||
usage: { output_tokens: 2, custom_terminal: true },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toMatchObject([
|
||||
{ type: "reasoning", text: "Thinking.", providerMetadata: { "custom-anthropic": { signature: "custom_sig" } } },
|
||||
{ type: "reasoning", text: "", providerMetadata: { "custom-anthropic": { redactedData: "custom_redacted" } } },
|
||||
{ type: "tool-call", id: "custom_tool", providerExecuted: true },
|
||||
{
|
||||
type: "tool-result",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { "custom-anthropic": { blockType: "web_search_tool_result", result } },
|
||||
},
|
||||
])
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
"custom-anthropic": { input_tokens: 5, custom_start: true, output_tokens: 2, custom_terminal: true },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
providerMetadata: { "custom-anthropic": { stopSequence: "custom_stop" } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Thinking.", signature: "custom_sig" },
|
||||
{ type: "redacted_thinking", data: "custom_redacted" },
|
||||
{ type: "server_tool_use", id: "custom_tool", name: "web_search", input: { query: "example" } },
|
||||
{ type: "web_search_tool_result", tool_use_id: "custom_tool", content: result },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -569,57 +569,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips reassigned provider reasoning and usage metadata in its own namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = model.route.with({ provider: "custom-bedrock" }).model({ id: model.id })
|
||||
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: compatible })).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "custom_sig" } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 1, delta: { reasoningContent: { redactedContent: redactedData } } },
|
||||
],
|
||||
["contentBlockStop", { contentBlockIndex: 1 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Let me think.",
|
||||
providerMetadata: { "custom-bedrock": { signature: "custom_sig" } },
|
||||
},
|
||||
{ type: "reasoning", text: "", providerMetadata: { "custom-bedrock": { redactedData } } },
|
||||
])
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
"custom-bedrock": { inputTokens: 5, outputTokens: 2, totalTokens: 7 },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "custom_sig" } } },
|
||||
{ reasoningContent: { redactedContent: redactedData } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves reasoning signatures when contentBlockStop is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
|
||||
@@ -36,23 +36,6 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
protocol: "openai-responses",
|
||||
body: { model: "openai.gpt-oss-120b", store: false },
|
||||
})
|
||||
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
expect(provider.responses("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves configured top-p generation defaults for Chat and Responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const settings = { apiKey: "test-key", topP: 0.8 }
|
||||
const chat = yield* compileRequest(
|
||||
LLM.request({ model: AmazonBedrockMantle.chatModel("openai.gpt-oss-safeguard-20b", settings), prompt: "Hi" }),
|
||||
)
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: AmazonBedrockMantle.responsesModel("openai.gpt-oss-120b", settings), prompt: "Hi" }),
|
||||
)
|
||||
|
||||
expect(chat.body.top_p).toBe(0.8)
|
||||
expect(responses.body.top_p).toBe(0.8)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -124,9 +107,6 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
LLM.request({ model, messages: [response.message, Message.user("Continue.")] }),
|
||||
)
|
||||
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
mantle: { itemId: "msg_95d4d0af4350432a", reasoningEncryptedContent: "mantle-state" },
|
||||
})
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("Cloudflare", () => {
|
||||
expect(response.reasoning).toBe("Thinking")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
"cloudflare-ai-gateway": { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
|
||||
@@ -6,7 +6,7 @@ import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexRespo
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
import { deltaChunk, finishChunk } from "../lib/openai-chunks.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("Google Vertex providers", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { vertex: { functionCallId: "provider_call_1" } },
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
]),
|
||||
Message.tool({
|
||||
@@ -97,7 +97,7 @@ describe("Google Vertex providers", () => {
|
||||
name: "lookup",
|
||||
result: "sunny",
|
||||
resultType: "text",
|
||||
providerMetadata: { vertex: { functionCallId: "provider_call_1" } },
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -122,91 +122,6 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips Vertex Gemini metadata through signed content, tool calls, and usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
project: "vertex-project",
|
||||
}).model("gemini-3.5-flash")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Check the weather." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
|
||||
{ text: "Checking.", thoughtSignature: "text_sig" },
|
||||
{
|
||||
functionCall: { id: "provider_call_1", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "tool_sig",
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
promptFeedback: { blockReasonMessage: "Reviewed" },
|
||||
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2, thoughtsTokenCount: 1 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const reasoning = response.events.find((event) => event.type === "reasoning-end")
|
||||
const text = response.events.find((event) => event.type === "text-delta")
|
||||
const toolCall = response.toolCalls[0]
|
||||
|
||||
expect(reasoning?.providerMetadata).toEqual({ vertex: { thoughtSignature: "reasoning_sig" } })
|
||||
expect(text?.providerMetadata).toEqual({ vertex: { thoughtSignature: "text_sig" } })
|
||||
expect(toolCall).toMatchObject({
|
||||
id: "provider_call_1",
|
||||
providerMetadata: { vertex: { thoughtSignature: "tool_sig" } },
|
||||
})
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
vertex: { promptTokenCount: 5, candidatesTokenCount: 2, thoughtsTokenCount: 1 },
|
||||
})
|
||||
expect(response.events.at(-1)?.providerMetadata).toEqual({
|
||||
vertex: { promptFeedback: { blockReasonMessage: "Reviewed" } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "Thinking.", providerMetadata: reasoning?.providerMetadata },
|
||||
{ type: "text", text: "Checking.", providerMetadata: text?.providerMetadata },
|
||||
ToolCallPart.make({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
input: toolCall.input,
|
||||
providerMetadata: toolCall.providerMetadata,
|
||||
}),
|
||||
]),
|
||||
Message.tool({ id: toolCall.id, name: toolCall.name, result: "sunny", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
|
||||
{ text: "Checking.", thoughtSignature: "text_sig" },
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "sunny" } } }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertexMessages.configure({
|
||||
|
||||
@@ -2,80 +2,13 @@ import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolDefinition } from "../../src/index.js"
|
||||
import {
|
||||
AmazonBedrock,
|
||||
AmazonBedrockMantle,
|
||||
Anthropic,
|
||||
AnthropicCompatible,
|
||||
Azure,
|
||||
Cerebras,
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
DeepInfra,
|
||||
Google,
|
||||
GoogleVertex,
|
||||
GoogleVertexChat,
|
||||
GoogleVertexMessages,
|
||||
GoogleVertexResponses,
|
||||
Groq,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenAICompatibleResponses,
|
||||
OpenRouter,
|
||||
TogetherAI,
|
||||
XAI,
|
||||
} from "../../src/providers/index.js"
|
||||
import { Cerebras, DeepInfra, Groq, TogetherAI } from "../../src/providers/index.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
describe("native OpenAI-compatible providers", () => {
|
||||
it.effect("assigns provider-owned metadata namespaces across native routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const vertex = { project: "project", accessToken: "token" }
|
||||
const providers = [
|
||||
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
|
||||
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
|
||||
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
|
||||
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
|
||||
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
|
||||
[Google.configure({ apiKey: "test" }).model("model"), "google"],
|
||||
[GoogleVertex.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
|
||||
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
|
||||
[
|
||||
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
|
||||
"minimax",
|
||||
],
|
||||
[
|
||||
OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
|
||||
"custom",
|
||||
],
|
||||
[
|
||||
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model(
|
||||
"model",
|
||||
),
|
||||
"custom",
|
||||
],
|
||||
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
|
||||
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
|
||||
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
|
||||
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
|
||||
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
|
||||
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
|
||||
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
|
||||
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
|
||||
] as const
|
||||
|
||||
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
|
||||
Effect.gen(function* () {
|
||||
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
|
||||
|
||||
@@ -68,13 +68,11 @@ for (const item of cases) {
|
||||
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata?.[
|
||||
item.model.route.providerMetadataKey ?? String(item.model.provider)
|
||||
]
|
||||
expect(metadata?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
|
||||
expect(Array.isArray(metadata?.reasoningDetails)).toBe(item.structured)
|
||||
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
|
||||
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
|
||||
expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
|
||||
if (!item.structured) return
|
||||
const details = metadata?.reasoningDetails
|
||||
const details = metadata?.openai?.reasoningDetails
|
||||
if (!Array.isArray(details)) return
|
||||
expect(
|
||||
details.some(
|
||||
@@ -128,11 +126,7 @@ for (const item of cases) {
|
||||
).toMatch(/^Paris is sunny\.?$/)
|
||||
const details = events
|
||||
.filter(LLMEvent.is.reasoningEnd)
|
||||
.map(
|
||||
(event) =>
|
||||
event.providerMetadata?.[item.model.route.providerMetadataKey ?? String(item.model.provider)]
|
||||
?.reasoningDetails,
|
||||
)
|
||||
.map((event) => event.providerMetadata?.openai?.reasoningDetails)
|
||||
.find(Array.isArray)
|
||||
expect(Array.isArray(details)).toBe(item.structured)
|
||||
if (!item.structured || !Array.isArray(details)) return
|
||||
|
||||
@@ -903,70 +903,6 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the configured provider metadata namespace for reasoning and usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const selected = LanguageModel.update(model, {
|
||||
route: { ...model.route, providerMetadataKey: "vendor" },
|
||||
})
|
||||
const details = [{ type: "reasoning.text", text: "thinking", signature: "signed" }]
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: selected })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
|
||||
deltaChunk({ content: "Hello" }),
|
||||
deltaChunk({}, "stop"),
|
||||
usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
vendor: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
vendor: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model: selected, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the selected provider for the metadata namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = model.route.with({ provider: "deepseek" }).model({ id: "deepseek-chat" })
|
||||
const selected = LanguageModel.update(compatible, {
|
||||
route: { ...compatible.route, providerMetadataKey: undefined },
|
||||
})
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: selected })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ reasoning_content: "thinking" }),
|
||||
deltaChunk({ content: "Hello" }),
|
||||
deltaChunk({}, "stop"),
|
||||
usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
deepseek: { reasoningField: "reasoning_content" },
|
||||
})
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
deepseek: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model: selected, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_content: "thinking" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses and replays a configured custom reasoning field", () =>
|
||||
Effect.gen(function* () {
|
||||
const custom = LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
|
||||
|
||||
@@ -437,7 +437,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
outputTokens: undefined,
|
||||
totalTokens: undefined,
|
||||
providerMetadata: {
|
||||
deepseek: {
|
||||
openai: {
|
||||
prompt_tokens: null,
|
||||
completion_tokens: null,
|
||||
total_tokens: null,
|
||||
|
||||
@@ -195,19 +195,19 @@ describe("Open Responses-compatible route", () => {
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "text", text: "Kept.", providerMetadata: { "openai-compatible": { itemId: "history_1" } } },
|
||||
{ type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } },
|
||||
{
|
||||
type: "text",
|
||||
text: "Long.",
|
||||
providerMetadata: { "openai-compatible": { itemId: `history_${"a".repeat(64)}` } },
|
||||
providerMetadata: { openresponses: { itemId: `history_${"a".repeat(64)}` } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Opaque.",
|
||||
providerMetadata: { "openai-compatible": { itemId: "provider_value/with+symbols" } },
|
||||
providerMetadata: { openresponses: { itemId: "provider_value/with+symbols" } },
|
||||
},
|
||||
{ type: "text", text: "No suffix.", providerMetadata: { "openai-compatible": { itemId: "msg_" } } },
|
||||
{ type: "text", text: "No prefix.", providerMetadata: { "openai-compatible": { itemId: "_item" } } },
|
||||
{ type: "text", text: "No suffix.", providerMetadata: { openresponses: { itemId: "msg_" } } },
|
||||
{ type: "text", text: "No prefix.", providerMetadata: { openresponses: { itemId: "_item" } } },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
@@ -267,7 +267,7 @@ describe("Open Responses-compatible route", () => {
|
||||
name: item.type,
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { example: { itemId: item.id } },
|
||||
providerMetadata: { openresponses: { itemId: item.id } },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -302,7 +302,7 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Indexed", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
|
||||
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -368,7 +368,7 @@ describe("Open Responses-compatible route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
const metadata = { "openai-compatible": { itemId: routing.id } }
|
||||
const metadata = { openresponses: { itemId: routing.id } }
|
||||
if (fixture.item.type === "function_call") {
|
||||
expect(response.toolCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -386,7 +386,7 @@ describe("Open Responses-compatible route", () => {
|
||||
type: "reasoning",
|
||||
text: "Preserved",
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
|
||||
openresponses: { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -438,26 +438,22 @@ describe("Open Responses-compatible route", () => {
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "First.",
|
||||
providerMetadata: { "openai-compatible": { itemId: routing.id } },
|
||||
providerMetadata: { openresponses: { itemId: routing.id } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Second.",
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
},
|
||||
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: `${routing.id}:0`,
|
||||
providerMetadata: { "openai-compatible": { itemId: routing.id } },
|
||||
providerMetadata: { openresponses: { itemId: routing.id } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: `${routing.id}:1`,
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
},
|
||||
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
|
||||
}),
|
||||
])
|
||||
}),
|
||||
@@ -487,7 +483,7 @@ describe("Open Responses-compatible route", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "complete" },
|
||||
providerMetadata: { "openai-compatible": { itemId: "" } },
|
||||
providerMetadata: { openresponses: { itemId: "" } },
|
||||
}),
|
||||
])
|
||||
}),
|
||||
@@ -514,7 +510,7 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Before after", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
|
||||
{ type: "text", text: "Before after", providerMetadata: { openresponses: { itemId: "msg_1" } } },
|
||||
])
|
||||
expect(response.events.map((event) => event.type)).toEqual([
|
||||
"step-start",
|
||||
@@ -666,7 +662,7 @@ describe("Open Responses-compatible route", () => {
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
input: { query: "complete" },
|
||||
providerMetadata: { example: { itemId: "item_1" } },
|
||||
providerMetadata: { openresponses: { itemId: "item_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -698,7 +694,7 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
|
||||
providerMetadata: { openresponses: { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -747,7 +743,7 @@ describe("Open Responses-compatible route", () => {
|
||||
Message.assistant({
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { "openai-compatible": { phase: null } },
|
||||
providerMetadata: { openresponses: { phase: null } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -806,7 +802,7 @@ describe("Open Responses-compatible route", () => {
|
||||
{
|
||||
type: "text",
|
||||
text: "I can't help with that.",
|
||||
providerMetadata: { example: { itemId: "msg_refusal" } },
|
||||
providerMetadata: { openresponses: { itemId: "msg_refusal" } },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -895,7 +891,7 @@ describe("Open Responses-compatible route", () => {
|
||||
|
||||
expect(response.toolCalls).toEqual([])
|
||||
expect(response.events.find(LLMEvent.is.finish)).toMatchObject({
|
||||
providerMetadata: { example: { responseId: "resp_1" } },
|
||||
providerMetadata: { openresponses: { responseId: "resp_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -295,7 +295,7 @@ describe("OpenRouter", () => {
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
@@ -328,7 +328,7 @@ describe("OpenRouter", () => {
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -354,7 +354,7 @@ describe("OpenRouter", () => {
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
text: "AB",
|
||||
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -40,13 +40,4 @@ describe("Route.with", () => {
|
||||
"x-patch": "patch",
|
||||
})
|
||||
})
|
||||
|
||||
test("assigns metadata ownership to a replacement provider and preserves explicit overrides", () => {
|
||||
const route = OpenAIChat.route.with({ provider: "azure" })
|
||||
const overridden = route.with({ providerMetadataKey: "custom-azure" }).with({ headers: { "x-test": "value" } })
|
||||
|
||||
expect(route.providerMetadataKey).toBe("azure")
|
||||
expect(overridden.providerMetadataKey).toBe("custom-azure")
|
||||
expect(overridden.defaults).not.toHaveProperty("providerMetadataKey")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
src/assets/theme.css
|
||||
e2e/test-results
|
||||
e2e/playwright-report
|
||||
component-tests/test-results
|
||||
component-tests/playwright-report
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
|
||||
story("shows the thinking level control while relevant", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
const composer = component.locator('[data-component="composer"]')
|
||||
const input = composer.locator('[data-component="composer-editor"]')
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
|
||||
await page.mouse.move(0, 0)
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
await control.click()
|
||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||
await expect(high).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toBeVisible()
|
||||
await expect(high).toBeVisible()
|
||||
await high.click()
|
||||
|
||||
await input.focus()
|
||||
await expect(control).toBeVisible()
|
||||
await input.blur()
|
||||
await expect(control).toBeVisible()
|
||||
})
|
||||
@@ -12,6 +12,54 @@ test.beforeEach(async ({ page }) => {
|
||||
await openReview(page)
|
||||
})
|
||||
|
||||
test("opens the comment editor when code is clicked", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const line = review.getByText("export const value = 'after'", { exact: true })
|
||||
await expectAppVisible(line)
|
||||
await line.click()
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
|
||||
})
|
||||
|
||||
test("opens the comment editor when a line number is clicked", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const lineNumber = review.locator('[data-column-number="1"]').last()
|
||||
await expectAppVisible(lineNumber)
|
||||
await lineNumber.click()
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
})
|
||||
|
||||
test("opens the comment editor for a line number range", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const start = review.locator('[data-column-number="1"]').last()
|
||||
const end = review.locator('[data-column-number="3"]').last()
|
||||
await expectAppVisible(start)
|
||||
await expectAppVisible(end)
|
||||
|
||||
await start.dragTo(end)
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
|
||||
})
|
||||
|
||||
test("shows a comment button when a diff line is hovered", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const line = review.getByText("export const first = 1", { exact: true })
|
||||
await expectAppVisible(line)
|
||||
|
||||
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
|
||||
await expect(comment).toHaveCount(1)
|
||||
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
|
||||
await expect(comment).toBeVisible()
|
||||
await expect(comment).toHaveCSS("pointer-events", "auto")
|
||||
await comment.dispatchEvent("click")
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
})
|
||||
|
||||
test("stages a submitted line comment in the prompt context", async ({ page }) => {
|
||||
page.on("request", (request) => {
|
||||
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
|
||||
|
||||
@@ -201,15 +201,12 @@ test("editing restores the existing draft and replaces only the original queue p
|
||||
await view.input.fill("my in-progress draft")
|
||||
await original.click()
|
||||
await expect(view.input).toHaveText("tighten the error copy")
|
||||
await expect(view.input).toBeFocused()
|
||||
await view.input.press("Escape")
|
||||
await expect(view.input).toHaveText("my in-progress draft")
|
||||
|
||||
await original.click()
|
||||
await expect(view.input).toHaveText("tighten the error copy")
|
||||
await expect(view.input).toBeFocused()
|
||||
await view.input.fill("tighten the error copy and add a retry hint")
|
||||
await expect(view.input).toHaveText("tighten the error copy and add a retry hint")
|
||||
await view.input.press("Enter")
|
||||
|
||||
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
|
||||
|
||||
@@ -1,43 +1,16 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
|
||||
const shellID = "prt_space_button_shell"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
shell(shellID, "completed", lines(5)),
|
||||
textPart(
|
||||
"prt_space_following",
|
||||
"Following content leaves room to focus the command away from the bottom. ".repeat(40),
|
||||
),
|
||||
]),
|
||||
],
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
reducedMotion: true,
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const trigger = page.getByRole("button", { name: "Used Shell" })
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
|
||||
.toBeGreaterThan(300)
|
||||
await trigger.scrollIntoViewIfNeeded()
|
||||
await scroller.hover()
|
||||
await page.mouse.wheel(0, -100)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeGreaterThan(50)
|
||||
await expect(trigger).toBeInViewport()
|
||||
await trigger.focus()
|
||||
await expect(trigger).toBeFocused()
|
||||
const before = await scroller.evaluate((element) => element.scrollTop)
|
||||
await trigger.press("Space")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -8,6 +8,21 @@ import {
|
||||
userText,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders completed write content", async ({ page }) => {
|
||||
const id = "prt_file_projection_write"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(id, "write", "completed", { path: "src/write.ts", content: "export const written = true\n" }),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders a completed single-file patch", async ({ page }) => {
|
||||
const id = "prt_file_projection_single_patch"
|
||||
await setupTimeline(page, {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
|
||||
test("keeps patch file disclosures independent", async ({ page }) => {
|
||||
const patchID = "prt_nested_patch"
|
||||
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
patchID,
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update three files" },
|
||||
{ metadata: { files } },
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
|
||||
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
|
||||
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
|
||||
await deleted.getByRole("button").click()
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await modified.getByRole("button").click()
|
||||
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await deleted.getByRole("button").click()
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
const before = status === "added" ? "" : source(false)
|
||||
const after = status === "deleted" ? "" : source(true)
|
||||
return {
|
||||
file,
|
||||
status,
|
||||
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
|
||||
additions: status === "deleted" ? 0 : 4,
|
||||
deletions: status === "added" ? 0 : 3,
|
||||
}
|
||||
}
|
||||
|
||||
function source(changed: boolean) {
|
||||
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
||||
"",
|
||||
)
|
||||
}
|
||||
@@ -122,7 +122,6 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
|
||||
@@ -130,7 +129,6 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
|
||||
await timeline.send(status("idle"), 300)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
|
||||
|
||||
@@ -122,7 +122,6 @@ test("updates running compactions to failed and cancelled boundaries", async ({
|
||||
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
|
||||
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
|
||||
await expect(page.getByText("Partial summary that should be discarded.", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
@@ -141,9 +140,6 @@ test("updates running compactions to failed and cancelled boundaries", async ({
|
||||
await expect(failed).not.toContainText("Partial summary that should be discarded.")
|
||||
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
|
||||
await expect(compactions).toHaveCount(2)
|
||||
await timeline.send(compactionDelta({ sessionID, text: "Summary before cancellation." }))
|
||||
await expect(page.getByText("Summary before cancellation.", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
@@ -156,7 +152,88 @@ test("updates running compactions to failed and cancelled boundaries", async ({
|
||||
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
|
||||
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
|
||||
await expect(cancelled).not.toContainText("Summary before cancellation.")
|
||||
})
|
||||
|
||||
test("shows a delegating row while subagent input streams", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
...assistant(false),
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const delegating = page.locator('[data-component="task-tool-delegating"]')
|
||||
await expect(delegating).toBeVisible()
|
||||
const shimmer = delegating.locator('[data-component="text-shimmer"]')
|
||||
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
|
||||
await expect(shimmer).toHaveCSS("line-height", "16px")
|
||||
const icon = delegating.locator('[data-slot="icon-svg"]')
|
||||
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
|
||||
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
|
||||
await expect(page.locator('[data-component="task-tool-card"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("renders the moved location notice in its compact timeline style", async ({ page }) => {
|
||||
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
|
||||
await page.setViewportSize({ width: 480, height: 720 })
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
id: "msg_location",
|
||||
type: "location-switched",
|
||||
location: { directory },
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const notice = page.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
|
||||
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
|
||||
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
|
||||
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
|
||||
|
||||
await expect(label).toHaveText("Moved to")
|
||||
await expect(value).toHaveText(directory)
|
||||
await expect(notice).not.toContainText("·")
|
||||
await expect(notice.locator("svg")).toHaveCount(0)
|
||||
await expect(notice).toHaveCSS("height", "28px")
|
||||
await expect(notice).toHaveCSS("gap", "8px")
|
||||
await expect(notice).toHaveCSS("padding-top", "4px")
|
||||
await expect(notice).toHaveCSS("padding-bottom", "4px")
|
||||
await expect(label).toHaveCSS("font-size", "13px")
|
||||
await expect(label).toHaveCSS("font-weight", "530")
|
||||
await expect(label).toHaveCSS("line-height", "16px")
|
||||
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
|
||||
await expect(value).toHaveCSS("font-size", "13px")
|
||||
await expect(value).toHaveCSS("font-weight", "440")
|
||||
await expect(value).toHaveCSS("line-height", "16px")
|
||||
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
|
||||
await expect(value).toHaveCSS("text-overflow", "ellipsis")
|
||||
await expect(value).toHaveCSS("white-space", "nowrap")
|
||||
await expect(value).toHaveAttribute("dir", "ltr")
|
||||
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
|
||||
|
||||
const tooltip = page.getByText("Session working directory changed", { exact: true })
|
||||
await label.hover()
|
||||
await expect(tooltip).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(tooltip).toBeHidden()
|
||||
await tooltipTrigger.focus()
|
||||
await expect(tooltipTrigger).toBeFocused()
|
||||
await expect(tooltip).toBeVisible()
|
||||
})
|
||||
|
||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
@@ -194,6 +271,11 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await request
|
||||
})
|
||||
|
||||
test("waits for completion before labeling requested background work", async ({ page }) => {
|
||||
await setupTimeline(page, { sessionMessages: [user, assistant(false, true, undefined, true)] })
|
||||
await expect(page.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
|
||||
})
|
||||
|
||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
||||
const childID = "ses_running_child"
|
||||
await setupTimeline(page, {
|
||||
|
||||
@@ -7,9 +7,86 @@ import {
|
||||
toolPart,
|
||||
userMessage,
|
||||
userText,
|
||||
type PartSeed,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test.describe("session timeline projection", () => {
|
||||
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_01_read", "read", "completed", { path: "src/a.ts" }),
|
||||
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
|
||||
toolPart("prt_04_list", "list", "completed", { path: "src" }),
|
||||
toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }),
|
||||
toolPart(
|
||||
"prt_websearch",
|
||||
"websearch",
|
||||
"completed",
|
||||
{ query: "timeline stability" },
|
||||
{ output: "https://example.com/result" },
|
||||
),
|
||||
toolPart("prt_task", "subagent", "completed", {
|
||||
description: "Inspect timeline",
|
||||
agent: "explore",
|
||||
prompt: "Inspect the timeline implementation.",
|
||||
}),
|
||||
toolPart(
|
||||
"prt_bash",
|
||||
"shell",
|
||||
"completed",
|
||||
{ command: "printf stable" },
|
||||
{ output: "stable", title: "printf stable" },
|
||||
),
|
||||
editPart("prt_edit"),
|
||||
toolPart("prt_write", "write", "completed", { path: "src/new.ts", content: "export const stable = true\n" }),
|
||||
patchPart("prt_patch"),
|
||||
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
toolPart(
|
||||
"prt_question",
|
||||
"question",
|
||||
"completed",
|
||||
{ questions: [{ question: "Keep stable?", header: "Stability", options: [] }] },
|
||||
{ metadata: { answers: [["Yes"]] } },
|
||||
),
|
||||
toolPart("prt_skill", "skill", "completed", { name: "stability" }),
|
||||
toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const first = page.locator(
|
||||
'[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list,prt_webfetch,prt_websearch,prt_task,prt_bash,prt_edit,prt_write,prt_patch"]',
|
||||
)
|
||||
const second = page.locator('[data-timeline-part-ids="prt_skill,prt_custom"]')
|
||||
await expect(first).toBeVisible()
|
||||
await expect(second).toBeVisible()
|
||||
await first.getByRole("button").click()
|
||||
await second.getByRole("button").click()
|
||||
for (const id of [
|
||||
"prt_webfetch",
|
||||
"prt_websearch",
|
||||
"prt_task",
|
||||
"prt_bash",
|
||||
"prt_edit",
|
||||
"prt_write",
|
||||
"prt_patch",
|
||||
"prt_question",
|
||||
"prt_skill",
|
||||
"prt_custom",
|
||||
]) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||
}
|
||||
const patch = page.locator('[data-timeline-part-id="prt_patch"]')
|
||||
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
|
||||
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
|
||||
await expect(patch.getByRole("button")).toHaveCount(1)
|
||||
await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1)
|
||||
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
|
||||
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
|
||||
const edit = page.locator('[data-timeline-part-id="prt_edit"]')
|
||||
await expect(edit).toContainText("Edit")
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("combines adjacent patch calls and repeated files into one group", async ({ page }) => {
|
||||
const first = "prt_patch_first"
|
||||
const second = "prt_patch_second"
|
||||
@@ -81,6 +158,43 @@ test.describe("session timeline projection", () => {
|
||||
await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("combines adjacent edit calls and repeated files into one group", async ({ page }) => {
|
||||
const first = "prt_edit_first"
|
||||
const second = "prt_edit_second"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
first,
|
||||
"edit",
|
||||
"completed",
|
||||
{ path: "src/first.ts", oldString: "one", newString: "two" },
|
||||
{
|
||||
metadata: { files: [patchFile("src/first.ts", "modified")] },
|
||||
},
|
||||
),
|
||||
toolPart(
|
||||
second,
|
||||
"edit",
|
||||
"completed",
|
||||
{ path: "src/first.ts", oldString: "two", newString: "three" },
|
||||
{
|
||||
metadata: { files: [patchFile("src/first.ts", "modified")] },
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
|
||||
await expect(group.getByText("1 file", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
|
||||
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
const firstUser = userMessage(
|
||||
[
|
||||
@@ -122,6 +236,25 @@ test.describe("session timeline projection", () => {
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
|
||||
const user = userMessage()
|
||||
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
|
||||
id: "msg_1001_before",
|
||||
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||
})
|
||||
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
|
||||
id: "msg_1002_after",
|
||||
created: 1700000003000,
|
||||
})
|
||||
await setupTimeline(page, { messages: [user, before, after] })
|
||||
|
||||
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||
const rows = await page
|
||||
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
|
||||
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
|
||||
})
|
||||
|
||||
test("renders aliased and long custom model notices", async ({ page }) => {
|
||||
const shortName = "GPT-5.4 nano"
|
||||
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
|
||||
@@ -158,8 +291,77 @@ test.describe("session timeline projection", () => {
|
||||
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
|
||||
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
})
|
||||
|
||||
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
|
||||
const text = "Use @explore with @src/a.ts and inspect the attachments"
|
||||
const parts: PartSeed<"user">[] = [
|
||||
userText(text, { id: "prt_user_rich" }),
|
||||
{
|
||||
id: "prt_user_image",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "pixel.png",
|
||||
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
},
|
||||
{
|
||||
id: "prt_user_attachment",
|
||||
type: "file",
|
||||
mime: "application/json",
|
||||
filename: "tsconfig.json",
|
||||
url: "data:application/json;base64,e30=",
|
||||
},
|
||||
{
|
||||
id: "prt_user_reference",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "a.ts",
|
||||
url: "src/a.ts",
|
||||
source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } },
|
||||
},
|
||||
{
|
||||
id: "prt_user_agent",
|
||||
type: "agent",
|
||||
name: "explore",
|
||||
source: { value: "@explore", start: 4, end: 12 },
|
||||
},
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] })
|
||||
|
||||
await expect(page.getByAltText("pixel.png")).toBeVisible()
|
||||
await expect(page.getByText("tsconfig.json")).toBeVisible()
|
||||
await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("@explore", { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
function editPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"edit",
|
||||
"completed",
|
||||
{ path: "src/a.ts", oldString: "export const value = 1", newString: "export const value = 2" },
|
||||
{
|
||||
metadata: {
|
||||
files: [patchFile("src/a.ts", "modified")],
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function patchPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update the projected files" },
|
||||
{
|
||||
metadata: {
|
||||
files: [patchFile("src/a.ts", "modified")],
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
return {
|
||||
file,
|
||||
|
||||
@@ -7,25 +7,33 @@ import {
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
toolPart,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
const textID = "prt_event_order_text"
|
||||
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 100)
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
|
||||
test("groups every collapsed tool until visible text separates the stack", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }),
|
||||
textPart("prt_boundary_02_text", "Boundary text"),
|
||||
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
|
||||
shell("prt_boundary_05_shell", "completed", "done"),
|
||||
toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
|
||||
"Final after early idle",
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
|
||||
const group = page.locator(
|
||||
'[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep,prt_boundary_05_shell,prt_boundary_06_list"]',
|
||||
)
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
|
||||
await expect(page.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
|
||||
await expect(page.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
|
||||
})
|
||||
|
||||
test("expands a mixed collapsed tool stack without expanding its individual calls", async ({ page }) => {
|
||||
@@ -124,3 +132,18 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
"Search timed out after 30 seconds",
|
||||
)
|
||||
})
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
const textID = "prt_event_order_text"
|
||||
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 100)
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
|
||||
"Final after early idle",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,31 @@ import {
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
||||
const ordinary = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
|
||||
const parts = ordinary.map((tool, index) =>
|
||||
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
||||
)
|
||||
parts.push(
|
||||
toolPart("prt_question_dismissed", "question", "error", questionInput(), {
|
||||
error: "The user dismissed this question",
|
||||
}),
|
||||
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
||||
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
||||
)
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ordinary.map((_, index) => `prt_error_${index}`).join(",")}"]`)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText(String(ordinary.length))
|
||||
await group.getByRole("button").click()
|
||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
||||
for (let index = 0; index < ordinary.length; index++) {
|
||||
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("transitions shell and question through running error outcomes", async ({ page }) => {
|
||||
const shellID = "prt_transition_error_shell"
|
||||
const questionID = "prt_transition_error_question"
|
||||
@@ -113,6 +138,62 @@ test("preserves surviving grouped patch state when its first patch fails", async
|
||||
.toBeGreaterThanOrEqual(-0.5)
|
||||
})
|
||||
|
||||
test("labels all web search provider variants", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart(
|
||||
"prt_search_parallel",
|
||||
"websearch",
|
||||
"completed",
|
||||
{ query: "parallel" },
|
||||
{ metadata: { provider: "parallel" } },
|
||||
),
|
||||
toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
|
||||
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
await page.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
|
||||
|
||||
const tools = page.locator('[data-component="context-tool-group-list"]')
|
||||
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
|
||||
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
await expect(tools.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("labels completed searches with result counts", async ({ page }) => {
|
||||
const glob = "prt_glob_count"
|
||||
const grep = "prt_grep_count"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(glob, "glob", "completed", { path: ".", pattern: "**/*.ts" }, { metadata: { count: 1 } }),
|
||||
toolPart(grep, "grep", "completed", { path: ".", pattern: "value" }, { metadata: { matches: 12 } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
|
||||
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
|
||||
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
|
||||
})
|
||||
|
||||
test("labels read tools from their path input", async ({ page }) => {
|
||||
const id = "prt_read_path"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(
|
||||
group
|
||||
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
|
||||
.filter({ hasText: "Read" }),
|
||||
).toContainText("a.ts")
|
||||
})
|
||||
|
||||
test("groups instruction files loaded by the same read", async ({ page }) => {
|
||||
const id = "prt_read_instructions"
|
||||
await setupTimeline(page, {
|
||||
@@ -140,6 +221,36 @@ test("groups instruction files loaded by the same read", async ({ page }) => {
|
||||
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
const pending = "prt_skill_id"
|
||||
const completed = "prt_skill_name"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(pending, "skill", "running", { id: "frontend-design" }),
|
||||
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${pending},${completed}"]`)
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
|
||||
await group.getByRole("button").click()
|
||||
|
||||
const loaded = group.locator('[data-component="tool-loaded-item"]')
|
||||
await expect(loaded).toHaveCount(1)
|
||||
await expect(loaded).toHaveAttribute("aria-label", "Loaded frontend-design, OpenCode skills")
|
||||
await expect(loaded).toHaveCSS("line-height", "16px")
|
||||
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
|
||||
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skills")
|
||||
const names = loaded.locator('[data-component="text-shimmer"]')
|
||||
await expect(names).toHaveCount(2)
|
||||
await expect(names.nth(0)).toHaveAttribute("aria-label", "frontend-design")
|
||||
await expect(names.nth(1)).toHaveAttribute("aria-label", "OpenCode")
|
||||
})
|
||||
|
||||
test("groups only consecutive successful skill tools", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_skill_first", "skill", "completed", { id: "ocpr" }),
|
||||
@@ -162,3 +273,14 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
|
||||
function questionInput() {
|
||||
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
||||
}
|
||||
|
||||
function errorInput(tool: string) {
|
||||
if (tool === "shell") return { command: "exit 1" }
|
||||
if (["edit", "write"].includes(tool)) return { path: "src/error.ts", content: "" }
|
||||
if (tool === "patch") return { patchText: "Update src/error.ts" }
|
||||
if (tool === "webfetch") return { url: "https://example.com" }
|
||||
if (tool === "websearch") return { query: "failure" }
|
||||
if (tool === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
|
||||
if (tool === "skill") return { name: "failure" }
|
||||
return { target: "failure" }
|
||||
}
|
||||
|
||||
@@ -87,7 +87,6 @@ test("clears the terminal line with Command+Delete", async ({ page }) => {
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
await expect.poll(() => sendPtyOutput).toBeDefined()
|
||||
|
||||
await page.keyboard.press("Meta+Backspace")
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
await expectStackedGeometry(page)
|
||||
await expectPanelGapHeld(page)
|
||||
|
||||
await resetTerminalTopMotion(page)
|
||||
await resetTerminalBottomMotion(page)
|
||||
await resetTerminalAnchorGaps(page)
|
||||
await resetPanelGaps(page)
|
||||
@@ -138,6 +139,7 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
await expect(panel).toBeVisible()
|
||||
await expectHeightMotions(page, "session-side-region", 2)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 2)
|
||||
await expectTerminalTopMotion(page)
|
||||
await expectTerminalBottomFixed(page)
|
||||
await expectTerminalTopAnchored(page)
|
||||
await expectPanelGapHeld(page)
|
||||
@@ -223,6 +225,7 @@ type MotionProbe = {
|
||||
terminalAnchorGaps: number[]
|
||||
resetAnchorOnMotion: boolean
|
||||
panelGaps: number[]
|
||||
terminalTops: number[]
|
||||
terminalBottoms: number[]
|
||||
heights: string[]
|
||||
animations: string[]
|
||||
@@ -240,6 +243,7 @@ async function installMotionProbe(page: Page) {
|
||||
terminalAnchorGaps: [],
|
||||
resetAnchorOnMotion: false,
|
||||
panelGaps: [],
|
||||
terminalTops: [],
|
||||
terminalBottoms: [],
|
||||
heights: [],
|
||||
animations: [],
|
||||
@@ -266,6 +270,7 @@ async function installMotionProbe(page: Page) {
|
||||
const terminalContent = document.querySelector<HTMLElement>('[data-slot="terminal-panel-content"]')
|
||||
const panelGap = document.querySelector<HTMLElement>('[data-slot="session-side-panel-gap"]')
|
||||
if (!terminal || !terminalContent) return
|
||||
probe.terminalTops.push(terminal.getBoundingClientRect().top)
|
||||
probe.terminalBottoms.push(terminal.getBoundingClientRect().bottom)
|
||||
probe.terminalContentSizes.push({
|
||||
width: terminalContent.getBoundingClientRect().width,
|
||||
@@ -441,6 +446,13 @@ async function expectStackPainted(page: Page) {
|
||||
expect(Math.max(...gaps.map((gap) => gap.terminalSurface)), JSON.stringify(gaps)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetTerminalTopMotion(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalTops = []
|
||||
})
|
||||
}
|
||||
|
||||
async function resetTerminalBottomMotion(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
@@ -504,6 +516,17 @@ async function expectTerminalContentCachedSize(page: Page) {
|
||||
expect(Math.min(...sizes.map((size) => size.height))).toBeGreaterThan(100)
|
||||
}
|
||||
|
||||
async function expectTerminalTopMotion(page: Page) {
|
||||
const tops = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [],
|
||||
)
|
||||
const unique = [...new Set(tops)]
|
||||
const range = Math.max(...unique) - Math.min(...unique)
|
||||
const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index])))
|
||||
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6)
|
||||
expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3)
|
||||
}
|
||||
|
||||
async function expectHeightMotions(page: Page, slot: string, count: number) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"rootDir": "..",
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../component-tests/**/*.ts", "../src/types.ts"]
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@
|
||||
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "playwright test",
|
||||
"test:components": "playwright test --config playwright.components.config.ts",
|
||||
"test:components:ui": "playwright test --config playwright.components.config.ts --ui",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { componentConfig } from "../storybook/playwright/config"
|
||||
|
||||
export default componentConfig(fileURLToPath(new URL(".", import.meta.url)))
|
||||
@@ -21,12 +21,6 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"component-tests",
|
||||
"playwright.components.config.ts",
|
||||
"../storybook/playwright/*.ts",
|
||||
"package.json"
|
||||
],
|
||||
"include": ["src", "package.json"],
|
||||
"exclude": ["dist", "ts-dist"]
|
||||
}
|
||||
|
||||
@@ -4,14 +4,11 @@ import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.restart,
|
||||
Effect.fn("cli.service.restart")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
// Keep this explicit: automatic service replacement must preserve terminals.
|
||||
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
|
||||
yield* Service.stop(options)
|
||||
const transport = yield* Service.ensure(options)
|
||||
process.stdout.write(transport.url + EOL)
|
||||
|
||||
@@ -514,115 +514,6 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
|
||||
expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
|
||||
test("event.subscribe keeps one request open while delivering multiple events", async () => {
|
||||
const requests: Request[] = []
|
||||
const events = [
|
||||
{ id: "evt_first", created: 1, type: "server.connected", data: {} },
|
||||
{ id: "evt_second", created: 2, type: "server.connected", data: {} },
|
||||
]
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
requests.push(input instanceof Request ? input : new Request(input, init))
|
||||
return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
},
|
||||
})
|
||||
const received = []
|
||||
for await (const event of client.event.subscribe()) received.push(event)
|
||||
expect(received).toEqual(events)
|
||||
expect(requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
|
||||
test("event.subscribe delivers every event from one stream chunk", async () => {
|
||||
const events = Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `evt_burst_${index}`,
|
||||
created: index,
|
||||
type: "server.connected",
|
||||
data: {},
|
||||
}))
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
new Response(new TextEncoder().encode(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
})
|
||||
const received = []
|
||||
for await (const event of client.event.subscribe()) received.push(event)
|
||||
expect(received).toEqual(events)
|
||||
expect(new Set(received.map((event) => event.id)).size).toBe(4)
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
|
||||
test("event.subscribe parses split JSON and a split multibyte code point", async () => {
|
||||
const event = {
|
||||
id: "evt_split",
|
||||
created: 1,
|
||||
type: "server.connected",
|
||||
data: { text: "split snowman \u2603\u2603\u2603" },
|
||||
}
|
||||
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
|
||||
const multibyte = encoded.indexOf(new TextEncoder().encode("\u2603")[0]!)
|
||||
const boundaries = [9, multibyte + 1, multibyte + 2, encoded.length]
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
boundaries.forEach((end, index) =>
|
||||
controller.enqueue(encoded.slice(index ? boundaries[index - 1] : 0, end)),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
})
|
||||
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
|
||||
test("event.subscribe ignores server heartbeat comments", async () => {
|
||||
const event = { id: "evt_sentinel", created: 1, type: "server.connected", data: {} }
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
new Response(`: heartbeat\n\ndata: ${JSON.stringify(event)}\n\n: heartbeat\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
})
|
||||
const received = []
|
||||
for await (const item of client.event.subscribe()) received.push(item)
|
||||
expect(received).toEqual([event])
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
|
||||
test("event transport passes through ordinary health requests", async () => {
|
||||
const requests: string[] = []
|
||||
const event = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(new URL(request.url).pathname)
|
||||
if (new URL(request.url).pathname === "/api/event") {
|
||||
return new Response(`data: ${JSON.stringify(event)}\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
return Response.json({ healthy: true, version: "2.0.0", pid: 1 })
|
||||
},
|
||||
})
|
||||
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
|
||||
await expect(client.health.get()).resolves.toEqual({ healthy: true, version: "2.0.0", pid: 1 })
|
||||
expect(requests).toEqual(["/api/event", "/api/health"])
|
||||
})
|
||||
|
||||
test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
|
||||
@@ -80,11 +80,10 @@ export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migrati
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
const label = credential.type === "oauth" ? "OAuth" : "API key"
|
||||
const now = Date.now()
|
||||
yield* tx.run(sql`
|
||||
INSERT INTO credential (id, integration_id, label, value, time_created, time_updated)
|
||||
VALUES (${Credential.ID.create()}, ${integrationID}, ${label}, ${JSON.stringify(credential)}, ${now}, ${now})
|
||||
VALUES (${Credential.ID.create()}, ${integrationID}, 'default', ${JSON.stringify(credential)}, ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
+94
-167
@@ -1,41 +1,11 @@
|
||||
export * as Job from "./job.js"
|
||||
|
||||
import { Array, Cause, Clock, Context, Deferred, Effect, Exit, Layer, Schema, Scope, SynchronizedRef } from "effect"
|
||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Identifier } from "./id/id.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
|
||||
const Background = Schema.Struct({
|
||||
id: Schema.String,
|
||||
notificationID: SessionMessage.ID,
|
||||
recovery: Schema.Union([
|
||||
Schema.Struct({
|
||||
kind: Schema.Literal("shell"),
|
||||
sessionID: SessionSchema.ID,
|
||||
shellID: Schema.String,
|
||||
command: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
kind: Schema.Literal("subagent"),
|
||||
parentSessionID: SessionSchema.ID,
|
||||
childSessionID: SessionSchema.ID,
|
||||
agent: Schema.String,
|
||||
description: Schema.String,
|
||||
}),
|
||||
]),
|
||||
status: Schema.Literals(["running", "completed", "error", "cancelled"]),
|
||||
output: Schema.optionalKey(Schema.String),
|
||||
error: Schema.optionalKey(Schema.String),
|
||||
})
|
||||
|
||||
export type Background = typeof Background.Type
|
||||
export type Recovery = Background["recovery"]
|
||||
export type Status = Background["status"]
|
||||
|
||||
const decodeBackground = Schema.decodeUnknownResult(Background)
|
||||
const backgroundPrefix = "job.background/"
|
||||
export type Status = "running" | "completed" | "error" | "cancelled"
|
||||
|
||||
export type Info = {
|
||||
id: string
|
||||
@@ -47,7 +17,6 @@ export type Info = {
|
||||
output?: string
|
||||
error?: string
|
||||
metadata?: Record<string, unknown>
|
||||
notificationID?: SessionMessage.ID
|
||||
}
|
||||
|
||||
type Active = {
|
||||
@@ -58,7 +27,6 @@ type Active = {
|
||||
token: object
|
||||
blockingSessions: Map<SessionSchema.ID, number>
|
||||
isBackgrounded: boolean
|
||||
recovery?: Recovery
|
||||
}
|
||||
|
||||
type State = {
|
||||
@@ -95,8 +63,6 @@ export type StartInput = {
|
||||
type: string
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
recovery?: Recovery
|
||||
notificationID?: SessionMessage.ID
|
||||
run: Effect.Effect<string, unknown>
|
||||
}
|
||||
|
||||
@@ -130,8 +96,6 @@ export interface Interface {
|
||||
readonly background: (id: string) => Effect.Effect<Info | undefined>
|
||||
readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect<Info[]>
|
||||
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
|
||||
readonly pendingBackground: Effect.Effect<readonly Background[]>
|
||||
readonly completeBackground: (notificationID: SessionMessage.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Job") {}
|
||||
@@ -162,57 +126,43 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes one scoped, process-local registry. Explicitly recoverable background
|
||||
* work also owns a durable notification marker until its notification is admitted.
|
||||
* Makes one scoped, process-local registry. Entries are intentionally not
|
||||
* durable: process restart or owner-scope closure loses status and interrupts
|
||||
* live work. Persisted observation, restart recovery, and remote workers need a
|
||||
* separate durable ownership slice rather than pretending this registry has
|
||||
* those semantics.
|
||||
*/
|
||||
export const make = Effect.gen(function* () {
|
||||
const kv = yield* KV.Service
|
||||
const state: State = {
|
||||
jobs: yield* SynchronizedRef.make(new Map()),
|
||||
scope: yield* Scope.Scope,
|
||||
}
|
||||
|
||||
const persistBackground = Effect.fnUntraced(function* (job: Active) {
|
||||
if (!job.recovery || !job.info.notificationID) return
|
||||
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
|
||||
id: job.info.id,
|
||||
notificationID: job.info.notificationID,
|
||||
recovery: job.recovery,
|
||||
status: job.info.status,
|
||||
...(job.info.output !== undefined ? { output: job.info.output } : {}),
|
||||
...(job.info.error !== undefined ? { error: job.info.error } : {}),
|
||||
})
|
||||
})
|
||||
|
||||
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.token !== token) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||
? "completed"
|
||||
: Cause.hasInterruptsOnly(exit.cause)
|
||||
? "cancelled"
|
||||
: "error"
|
||||
const next = {
|
||||
...job,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
info: {
|
||||
...job.info,
|
||||
status,
|
||||
completed_at,
|
||||
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
|
||||
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
|
||||
},
|
||||
}
|
||||
if (status !== "cancelled") yield* persistBackground(next)
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.token !== token) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||
? "completed"
|
||||
: Cause.hasInterruptsOnly(exit.cause)
|
||||
? "cancelled"
|
||||
: "error"
|
||||
const next = {
|
||||
...job,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
info: {
|
||||
...job.info,
|
||||
status,
|
||||
completed_at,
|
||||
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
|
||||
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
|
||||
},
|
||||
}
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
})
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) {
|
||||
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
|
||||
@@ -220,6 +170,22 @@ export const make = Effect.gen(function* () {
|
||||
return result.info
|
||||
})
|
||||
|
||||
const fork = Effect.fnUntraced(function* (
|
||||
scope: Scope.Scope,
|
||||
id: string,
|
||||
token: object,
|
||||
run: Effect.Effect<string, unknown>,
|
||||
) {
|
||||
return yield* run.pipe(
|
||||
Effect.matchCauseEffect({
|
||||
onSuccess: (output) => settle(id, token, Exit.succeed(output)),
|
||||
onFailure: (cause) => settle(id, token, Exit.failCause(cause)),
|
||||
}),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
const get: Interface["get"] = Effect.fn("Job.get")(function* (id) {
|
||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
|
||||
if (!job) return undefined
|
||||
@@ -235,10 +201,10 @@ export const make = Effect.gen(function* () {
|
||||
const backgrounded = yield* Deferred.make<Info>()
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [StartResult, Map<string, Active>]> {
|
||||
Effect.fnUntraced(function* (jobs) {
|
||||
const existing = jobs.get(id)
|
||||
if (existing?.info.status === "running") {
|
||||
return [{ info: snapshot(existing) }, jobs]
|
||||
return [{ info: snapshot(existing) }, jobs] as readonly [StartResult, Map<string, Active>]
|
||||
}
|
||||
const scope = yield* Scope.fork(state.scope, "parallel")
|
||||
const token = {}
|
||||
@@ -250,7 +216,6 @@ export const make = Effect.gen(function* () {
|
||||
status: "running" as const,
|
||||
started_at,
|
||||
metadata: input.metadata,
|
||||
...(input.notificationID ? { notificationID: input.notificationID } : {}),
|
||||
},
|
||||
done,
|
||||
backgrounded,
|
||||
@@ -258,18 +223,14 @@ export const make = Effect.gen(function* () {
|
||||
token,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
isBackgrounded: false,
|
||||
recovery: input.recovery,
|
||||
}
|
||||
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
|
||||
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
|
||||
StartResult,
|
||||
Map<string, Active>,
|
||||
]
|
||||
}),
|
||||
)
|
||||
if ("scope" in result)
|
||||
yield* restore(input.run).pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => settle(id, result.token, exit)),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(result.scope, { startImmediately: true }),
|
||||
)
|
||||
if ("scope" in result) yield* fork(result.scope, id, result.token, restore(input.run))
|
||||
return result.info
|
||||
}),
|
||||
)
|
||||
@@ -320,31 +281,20 @@ export const make = Effect.gen(function* () {
|
||||
).pipe(Effect.ensuring(removeBlock(input)))
|
||||
})
|
||||
|
||||
const markBackground = Effect.fnUntraced(function* (job: Active) {
|
||||
const next = {
|
||||
...job,
|
||||
isBackgrounded: true,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
info: {
|
||||
...job.info,
|
||||
...(job.recovery ? { notificationID: job.info.notificationID ?? SessionMessage.ID.create() } : {}),
|
||||
},
|
||||
}
|
||||
yield* persistBackground(next)
|
||||
return next
|
||||
})
|
||||
|
||||
const background: Interface["background"] = Effect.fn("Job.background")(function* (id) {
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
const result = yield* SynchronizedRef.modify(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [BackgroundResult, Map<string, Active>]> {
|
||||
(jobs): readonly [BackgroundResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
// Recoverable work may finish before the caller backgrounds it.
|
||||
if (!job || (job.info.status !== "running" && !job.recovery)) return [{}, jobs]
|
||||
if (!job || job.info.status !== "running") return [{}, jobs]
|
||||
if (job.isBackgrounded) return [{ info: snapshot(job) }, jobs]
|
||||
const next = yield* markBackground(job)
|
||||
const next = {
|
||||
...job,
|
||||
isBackgrounded: true,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
}
|
||||
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (result.info && result.backgrounded)
|
||||
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
|
||||
@@ -352,83 +302,60 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
const backgroundAll: Interface["backgroundAll"] = Effect.fn("Job.backgroundAll")(function* (input) {
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
const result = yield* SynchronizedRef.modify(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<
|
||||
readonly [Required<BackgroundResult>[], Map<string, Active>]
|
||||
> {
|
||||
const results: Required<BackgroundResult>[] = []
|
||||
(jobs): readonly [BackgroundResult[], Map<string, Active>] => {
|
||||
const results: BackgroundResult[] = []
|
||||
const next = new Map(jobs)
|
||||
for (const [id, job] of jobs) {
|
||||
if (job.info.status !== "running") continue
|
||||
if (job.isBackgrounded) continue
|
||||
if (input.type !== undefined && job.info.type !== input.type) continue
|
||||
if (!job.blockingSessions.has(input.sessionID)) continue
|
||||
const updated = yield* markBackground(job)
|
||||
const updated = {
|
||||
...job,
|
||||
isBackgrounded: true,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
}
|
||||
results.push({ info: snapshot(updated), backgrounded: job.backgrounded })
|
||||
next.set(id, updated)
|
||||
}
|
||||
return [results, next]
|
||||
}),
|
||||
},
|
||||
)
|
||||
yield* Effect.forEach(result, (item) => Deferred.succeed(item.backgrounded, item.info), { discard: true })
|
||||
return result.map((item) => item.info)
|
||||
yield* Effect.forEach(
|
||||
result,
|
||||
(item) => (item.info && item.backgrounded ? Deferred.succeed(item.backgrounded, item.info) : Effect.void),
|
||||
{ discard: true },
|
||||
)
|
||||
return result.flatMap((item) => (item.info ? [item.info] : []))
|
||||
})
|
||||
|
||||
const cancel: Interface["cancel"] = Effect.fn("Job.cancel")(function* (id) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const next = {
|
||||
...job,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
info: {
|
||||
...job.info,
|
||||
status: "cancelled" as const,
|
||||
completed_at,
|
||||
},
|
||||
}
|
||||
yield* persistBackground(next)
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const next = {
|
||||
...job,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
info: {
|
||||
...job.info,
|
||||
status: "cancelled" as const,
|
||||
completed_at,
|
||||
},
|
||||
}
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
})
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) yield* Scope.close(result.scope, Exit.void)
|
||||
return result.info
|
||||
})
|
||||
|
||||
const pendingBackground: Interface["pendingBackground"] = Effect.gen(function* () {
|
||||
const recovered: Background[] = []
|
||||
let after: string | undefined
|
||||
do {
|
||||
const page = yield* kv.scan({ prefix: backgroundPrefix, after })
|
||||
recovered.push(...Array.filterMap(page.entries, (entry) => decodeBackground(entry.value)))
|
||||
after = page.next
|
||||
} while (after)
|
||||
return recovered
|
||||
}).pipe(Effect.withSpan("Job.pendingBackground"))
|
||||
|
||||
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
|
||||
kv.remove(`${backgroundPrefix}${notificationID}`),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
get,
|
||||
start,
|
||||
wait,
|
||||
block,
|
||||
background,
|
||||
backgroundAll,
|
||||
cancel,
|
||||
pendingBackground,
|
||||
completeBackground,
|
||||
})
|
||||
return Service.of({ get, start, wait, block, background, backgroundAll, cancel })
|
||||
})
|
||||
|
||||
const layer = Layer.effect(Service, make)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
||||
@@ -207,10 +207,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
}
|
||||
if (!URL.canParse(config.url))
|
||||
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
||||
// Prefer raw tools for our Code Mode without changing the configured URL used for OAuth identity.
|
||||
const url = new URL(config.url)
|
||||
if (config.codemode !== false && !url.searchParams.has("codemode")) url.searchParams.set("codemode", "false")
|
||||
return new StreamableHTTPClientTransport(url, {
|
||||
return new StreamableHTTPClientTransport(new URL(config.url), {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
authProvider,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
|
||||
import { Cause, Context, Duration, Effect, Fiber, Layer, Schedule, Schema, Semaphore } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
@@ -10,7 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Model } from "./model.js"
|
||||
import { Provider } from "./provider.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { ModelsDevCache } from "./models-dev/cache.js"
|
||||
import snapshotText from "./models-dev/snapshot.txt" with { type: "text" }
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
@@ -539,13 +539,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mo
|
||||
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const decodeCatalog = (text: string) =>
|
||||
Schema.decodeUnknownEffect(CatalogJson)(text).pipe(Effect.map((catalog) => catalog as Record<string, SourceProvider>))
|
||||
const Cache = Schema.Struct({
|
||||
updatedAt: Schema.Number,
|
||||
// Digest of the raw body, persisted so refresh() can skip republishing a
|
||||
// byte-identical catalog. Optional for entries written before it existed.
|
||||
digest: Schema.optional(Schema.String),
|
||||
body: CatalogJson,
|
||||
})
|
||||
const defaultSource = "https://models.opencode.ai"
|
||||
|
||||
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
|
||||
@@ -554,23 +547,18 @@ const defaultSource = "https://models.opencode.ai"
|
||||
// isolate: the snapshot is a multi-MB module-level constant and one isolate can
|
||||
// host many runtimes (Cloudflare colocates Durable Object instances), so
|
||||
// per-runtime decoding would multiply the cost.
|
||||
let bundledCache: readonly Snapshot[] | undefined
|
||||
let bundledCache: { data: readonly Snapshot[]; digest: string } | undefined
|
||||
const bundledSnapshot = Effect.suspend(() =>
|
||||
bundledCache
|
||||
? Effect.succeed(bundledCache)
|
||||
: decodeCatalog(snapshotText).pipe(
|
||||
Effect.map((catalog) => {
|
||||
bundledCache = normalize(catalog)
|
||||
bundledCache = { data: normalize(catalog), digest: bodyDigest(snapshotText) }
|
||||
return bundledCache
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
function cacheKey(source: string) {
|
||||
if (source === defaultSource) return "models-dev:catalog"
|
||||
return `models-dev:catalog:${Hash.fast(source)}`
|
||||
}
|
||||
|
||||
export function bodyDigest(text: string) {
|
||||
return Hash.sha256(text)
|
||||
}
|
||||
@@ -582,7 +570,7 @@ export const layer = (options?: Options) =>
|
||||
const fs = yield* FSUtil.Service
|
||||
const bus = yield* Bus.Service
|
||||
const app = yield* App.Metadata
|
||||
const kv = yield* KV.Service
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const http = HttpClient.filterStatusOk(
|
||||
(yield* HttpClient.HttpClient).pipe(
|
||||
HttpClient.retryTransient({
|
||||
@@ -596,21 +584,9 @@ export const layer = (options?: Options) =>
|
||||
const source = options?.url || defaultSource
|
||||
const fetch = options?.fetch ?? true
|
||||
const userAgent = App.useragent(app)
|
||||
const key = cacheKey(source)
|
||||
const ttl = Duration.minutes(5)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const loadFromCache = Effect.fnUntraced(function* () {
|
||||
const value = yield* kv.get(key)
|
||||
const cached = Schema.decodeUnknownOption(Cache)(value)
|
||||
if (Option.isSome(cached))
|
||||
return {
|
||||
catalog: cached.value.body as Record<string, SourceProvider>,
|
||||
updatedAt: cached.value.updatedAt,
|
||||
digest: cached.value.digest,
|
||||
}
|
||||
if (value !== undefined) yield* kv.remove(key)
|
||||
})
|
||||
const state: { data?: readonly Snapshot[]; digest?: string; checkedAt: number } = { checkedAt: 0 }
|
||||
|
||||
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
|
||||
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
|
||||
@@ -621,79 +597,82 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
})
|
||||
|
||||
const loadFromFile = options?.file
|
||||
? fs.readJson(options.file).pipe(
|
||||
Effect.map((input) => input as Record<string, SourceProvider>),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
// Persistence only seeds a runtime. Refresh never reloads this seed over
|
||||
// a catalog that was successfully fetched but could not be saved.
|
||||
// The service owns initialization so cancelling a reader cannot cancel it.
|
||||
const initialized = yield* Effect.forkScoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
const stored = options?.file
|
||||
? { body: yield* fs.readFileString(options.file), updatedAt: Date.now() }
|
||||
: yield* cache.read(source)
|
||||
if (!stored) return
|
||||
const data = normalize(yield* decodeCatalog(stored.body))
|
||||
Object.assign(state, { data, digest: bodyDigest(stored.body), checkedAt: stored.updatedAt })
|
||||
}).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to load models.dev catalog cache", { cause }),
|
||||
),
|
||||
)
|
||||
: Effect.undefined
|
||||
if (state.data) return
|
||||
if (options?.snapshot !== false) {
|
||||
Object.assign(state, yield* bundledSnapshot)
|
||||
return
|
||||
}
|
||||
if (!fetch) state.data = []
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
|
||||
// The bundled snapshot is the boot-time floor for the catalog; the
|
||||
// periodic fetch below still refreshes on top.
|
||||
const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot
|
||||
|
||||
// Best-effort: a cache-write failure must never kill catalog
|
||||
// population. The payload has outgrown some KV backends' per-value
|
||||
// limits (Durable Object SQLite caps values at 2 MB and api.json
|
||||
// passed it in Aug 2026); a boot without a cache hit just refetches.
|
||||
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string) {
|
||||
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
|
||||
),
|
||||
)
|
||||
const update = Effect.fn("ModelsDev.update")(function* (force = false) {
|
||||
const text = options?.file ? yield* fs.readFileString(options.file) : yield* fetchApi()
|
||||
const digest = bodyDigest(text)
|
||||
if (!force && state.data && state.digest === digest) {
|
||||
state.checkedAt = Date.now()
|
||||
return state.data
|
||||
}
|
||||
const data = normalize(yield* decodeCatalog(text))
|
||||
Object.assign(state, { data, digest, checkedAt: Date.now() })
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
// Adopt and publish before attempting persistence. A missing or broken
|
||||
// cache must not prevent live updates, including in filesystem-less runtimes.
|
||||
if (!options?.file)
|
||||
yield* cache.write(source, text).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
|
||||
),
|
||||
)
|
||||
return data
|
||||
})
|
||||
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
const catalog = yield* decodeCatalog(text)
|
||||
yield* writeCache(text)
|
||||
return catalog
|
||||
const get = Effect.fn("ModelsDev.get")(function* () {
|
||||
yield* Fiber.join(initialized)
|
||||
if (state.data) return state.data
|
||||
return yield* lock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
return state.data ?? (yield* update())
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const populate = Effect.gen(function* () {
|
||||
const fromFile = yield* loadFromFile
|
||||
if (fromFile) return normalize(fromFile)
|
||||
const cached = options?.file ? undefined : yield* loadFromCache()
|
||||
if (cached) return normalize(cached.catalog)
|
||||
const bundled = yield* loadSnapshot
|
||||
if (bundled) return bundled
|
||||
if (!fetch) return []
|
||||
const catalog = yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const stored = options?.file ? undefined : yield* loadFromCache()
|
||||
if (stored) return stored.catalog
|
||||
return yield* fetchAndWrite()
|
||||
}),
|
||||
)
|
||||
return normalize(catalog)
|
||||
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
|
||||
|
||||
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
|
||||
|
||||
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
|
||||
|
||||
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
|
||||
yield* lock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const stored = yield* loadFromCache()
|
||||
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
|
||||
const text = yield* fetchApi()
|
||||
// models.dev rarely changes between polls; skip the cache write,
|
||||
// invalidation, and Refreshed event for a byte-identical body so
|
||||
// downstream catalog.updated listeners stay quiet.
|
||||
if (!force && stored?.digest === bodyDigest(text)) return
|
||||
yield* decodeCatalog(text)
|
||||
yield* writeCache(text)
|
||||
yield* invalidate
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
yield* Fiber.join(initialized)
|
||||
if (!force && Date.now() - state.checkedAt < Duration.toMillis(ttl)) return
|
||||
yield* update(force)
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
|
||||
Effect.ignore,
|
||||
Effect.orDie,
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logError("Failed to refresh models.dev", { cause }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -710,7 +689,7 @@ export function configured(options?: Options) {
|
||||
return makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
|
||||
deps: [FSUtil.node, Bus.node, App.node, ModelsDevCache.node, httpClient],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export * as ModelsDevCache from "./cache.js"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, FileSystem, Layer, Option } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
|
||||
export interface Entry {
|
||||
readonly body: string
|
||||
readonly updatedAt: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (source: string) => Effect.Effect<Entry | undefined, PlatformError>
|
||||
readonly write: (source: string, body: string) => Effect.Effect<void, PlatformError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDevCache") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.cache, "models-dev")
|
||||
|
||||
const read = Effect.fn("ModelsDevCache.read")(
|
||||
function* (source: string) {
|
||||
const file = path.join(directory, `${Hash.fast(source)}.json`)
|
||||
const body = yield* fs.readFileString(file)
|
||||
const info = yield* fs.stat(file)
|
||||
return { body, updatedAt: Option.getOrUndefined(info.mtime)?.getTime() ?? 0 }
|
||||
},
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
|
||||
)
|
||||
|
||||
const write = Effect.fn("ModelsDevCache.write")(function* (source: string, body: string) {
|
||||
yield* fs.makeDirectory(directory, { recursive: true })
|
||||
const temporary = yield* fs.makeTempFileScoped({ directory, prefix: ".tmp-" })
|
||||
yield* fs.writeFileString(temporary, body)
|
||||
yield* fs.rename(temporary, path.join(directory, `${Hash.fast(source)}.json`))
|
||||
}, Effect.scoped)
|
||||
|
||||
return Service.of({ read, write })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [LayerNodePlatform.filesystem, Global.node],
|
||||
})
|
||||
|
||||
export const disabledLayer = Layer.succeed(
|
||||
Service,
|
||||
Service.of({ read: () => Effect.undefined, write: () => Effect.void }),
|
||||
)
|
||||
@@ -1,99 +0,0 @@
|
||||
export * as GoalPlugin from "./goal.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Stream } from "effect"
|
||||
|
||||
type GoalState = {
|
||||
goal: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
let workerStarted = false
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.goal",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const key = (sessionID: Session.ID) => `session/${sessionID}/goal`
|
||||
const read = Effect.fn(function* (sessionID: Session.ID) {
|
||||
return (yield* ctx.storage.get(key(sessionID))) as GoalState | undefined
|
||||
})
|
||||
|
||||
const evaluate = Effect.fn(function* (sessionID: Session.ID) {
|
||||
const state = yield* read(sessionID)
|
||||
if (!state?.active) return
|
||||
|
||||
const result = yield* ctx.session.generate({
|
||||
sessionID,
|
||||
prompt: [
|
||||
"Evaluate progress toward the goal below using the current session context.",
|
||||
"Reply with exactly COMPLETE if it is fully complete.",
|
||||
"Otherwise reply with CONTINUE followed by one concise instruction for the next step.",
|
||||
`Goal: ${state.goal}`,
|
||||
].join("\n\n"),
|
||||
})
|
||||
const current = yield* read(sessionID)
|
||||
if (!current?.active || current.goal !== state.goal) return
|
||||
|
||||
const evaluation = result.text.trim()
|
||||
if (/^COMPLETE\b/i.test(evaluation)) {
|
||||
yield* ctx.session.synthetic({
|
||||
sessionID,
|
||||
text: `Goal: ${state.goal}\n\nThe goal has been completed.`,
|
||||
description: "Goal completed",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
})
|
||||
yield* ctx.storage.set(key(sessionID), { goal: state.goal, active: false })
|
||||
return
|
||||
}
|
||||
|
||||
yield* ctx.session.synthetic({
|
||||
sessionID,
|
||||
text: [
|
||||
`Goal: ${state.goal}`,
|
||||
`Next step: ${evaluation.replace(/^CONTINUE\s*/i, "")}`,
|
||||
"Continue working autonomously until the goal is complete.",
|
||||
].join("\n\n"),
|
||||
description: "Goal continuing",
|
||||
delivery: "steer",
|
||||
resume: true,
|
||||
})
|
||||
})
|
||||
|
||||
if (!workerStarted) {
|
||||
workerStarted = true
|
||||
yield* ctx.event
|
||||
.subscribe()
|
||||
.pipe(
|
||||
Stream.mapEffect((event) => {
|
||||
if (event.type !== "session.execution.succeeded") return Effect.void
|
||||
return evaluate(event.data.sessionID).pipe(
|
||||
Effect.catch((error) => Effect.logError("goal evaluation failed", { sessionID: event.data.sessionID, error })),
|
||||
)
|
||||
}),
|
||||
Stream.runDrain,
|
||||
Effect.forkDetach,
|
||||
)
|
||||
}
|
||||
|
||||
yield* ctx.command.transform((draft) => {
|
||||
draft.add({
|
||||
name: "goal",
|
||||
description: "Work autonomously toward a goal",
|
||||
execute: Effect.fn(function* ({ sessionID, prompt, delivery }) {
|
||||
const goal = prompt.text.trim()
|
||||
if (!goal) return yield* Effect.fail(new Error("Usage: /goal <goal>"))
|
||||
yield* ctx.storage.set(key(sessionID), { goal, active: true })
|
||||
yield* ctx.session.synthetic({
|
||||
sessionID,
|
||||
text: `Goal: ${goal}\n\nContinue until the goal is fully complete. Use tools and make changes as needed.`,
|
||||
description: `Goal started: ${goal}`,
|
||||
delivery,
|
||||
resume: true,
|
||||
})
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -384,7 +384,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
add: (tool) => draft.add(tool),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.as({ dispose: Effect.void })),
|
||||
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
|
||||
hook: (name, callback) => hooks.register("tool", name, callback),
|
||||
},
|
||||
vcs: {
|
||||
|
||||
@@ -76,7 +76,6 @@ import { WellKnown } from "../wellknown.js"
|
||||
import { WriteTool } from "../tool/plugin/write.js"
|
||||
import { AgentPlugin } from "./agent.js"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { GoalPlugin } from "./goal.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
import { MCPCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
|
||||
@@ -242,7 +241,6 @@ const pre = [
|
||||
AgentPlugin.Plugin,
|
||||
PlanPlugin.Plugin,
|
||||
CommandPlugin.Plugin,
|
||||
GoalPlugin.Plugin,
|
||||
SkillPlugin.Plugin,
|
||||
VcsHgPlugin.Plugin,
|
||||
...SystemPromptPlugin.Plugins,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
|
||||
// These servers provide Code Mode, so expose them directly instead of nesting them inside OpenCode Code Mode.
|
||||
const urls = [/^https:\/\/executor\.sh\/[^/]+\/mcp$/]
|
||||
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.mcp.codemode.exclusion",
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface Interface {
|
||||
| "wait"
|
||||
| "context"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel" | "completeBackground">
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||
readonly location: {
|
||||
readonly agent: {
|
||||
readonly list: (
|
||||
@@ -92,8 +92,6 @@ export const layerWithCell = (cell: Cell) =>
|
||||
block: (input) => require(cell, (runtime) => runtime.job.block(input)),
|
||||
background: (id) => require(cell, (runtime) => runtime.job.background(id)),
|
||||
cancel: (id) => require(cell, (runtime) => runtime.job.cancel(id)),
|
||||
completeBackground: (notificationID) =>
|
||||
require(cell, (runtime) => runtime.job.completeBackground(notificationID)),
|
||||
},
|
||||
location: {
|
||||
agent: {
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as SessionExecution from "./execution.js"
|
||||
import { Cause, Context, Effect, Exit, Layer } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Job } from "../job.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionEvent } from "./event.js"
|
||||
@@ -53,7 +52,6 @@ export const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
||||
effect.pipe(
|
||||
@@ -120,7 +118,6 @@ export const layer = Layer.effect(
|
||||
if (outcome.type === "interrupted") {
|
||||
// A user cancel releases the claim: the turn must not resurrect at the next
|
||||
// boot. Shutdown interruption keeps it for restart continuity.
|
||||
if (outcome.reason === "user") yield* jobs.cancel(sessionID)
|
||||
yield* bus.publish(
|
||||
SessionEvent.Execution.Interrupted,
|
||||
{ sessionID, reason: outcome.reason },
|
||||
@@ -170,7 +167,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
|
||||
@@ -3,8 +3,6 @@ export * as SessionRestart from "./restart.js"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Job } from "../../job.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionExecution } from "../execution.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
@@ -47,9 +45,6 @@ export interface Interface {
|
||||
* process: crash, SIGKILL, isolate eviction, and graceful restart all leave
|
||||
* the same durable signature.
|
||||
*
|
||||
* Recovery is at-least-once: local coordination prevents concurrent drains,
|
||||
* not repeated external side effects after a crash.
|
||||
*
|
||||
* The sweep assumes every orphaned claim's owner is dead. The managed-server
|
||||
* protocol guarantees this: a successor is only spawned after the previous
|
||||
* process is confirmed dead (client service `kill`/`evict` poll the PID), the
|
||||
@@ -67,16 +62,14 @@ export const layer = (options?: Options) =>
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const sessions = yield* Session.Service
|
||||
const scope = yield* Effect.scope
|
||||
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
|
||||
|
||||
const prepareResume = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
const resumeOne = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
// Durable before the resume runs, so a crash inside the resumed turn is
|
||||
// counted by the next sweep and the budget cannot be dodged.
|
||||
const attempts = yield* store.countResume(sessionID)
|
||||
if (attempts === undefined) return false
|
||||
if (attempts === undefined) return // the Session was deleted since listing
|
||||
if (attempts > maxAttempts) {
|
||||
// Terminalize instead: the release hook clears the claim and resets the
|
||||
// counter atomically with the terminal event.
|
||||
@@ -85,166 +78,31 @@ export const layer = (options?: Options) =>
|
||||
{ sessionID, error: RESUME_EXHAUSTED },
|
||||
{ commit: () => store.release(sessionID) },
|
||||
)
|
||||
return false
|
||||
return
|
||||
}
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_SERVER_RESTART,
|
||||
description: "Continuing after restart",
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
const recoverShell = Effect.fnUntraced(function* (
|
||||
background: Job.Background,
|
||||
recovery: Extract<Job.Recovery, { kind: "shell" }>,
|
||||
) {
|
||||
const state = background.status === "running" ? "cancelled" : background.status
|
||||
const text =
|
||||
background.status === "running"
|
||||
? "Command cancelled because the server restarted"
|
||||
: state === "completed"
|
||||
? (background.output ?? "Command completed")
|
||||
: state === "error"
|
||||
? (background.error ?? "Command failed")
|
||||
: "Command cancelled"
|
||||
|
||||
yield* sessions
|
||||
.synthetic({
|
||||
id: background.notificationID,
|
||||
sessionID: recovery.sessionID,
|
||||
description: recovery.command,
|
||||
text: `<shell id="${background.id}" state="${state}" command="${recovery.command}">\n${text}\n</shell>`,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: background.id,
|
||||
shellID: recovery.shellID,
|
||||
state,
|
||||
},
|
||||
resume: false,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.void),
|
||||
Effect.orDie,
|
||||
)
|
||||
yield* jobs.completeBackground(background.notificationID)
|
||||
})
|
||||
|
||||
const recoverSubagent = Effect.fnUntraced(function* (
|
||||
background: Job.Background,
|
||||
recovery: Extract<Job.Recovery, { kind: "subagent" }>,
|
||||
suspended: ReadonlySet<SessionSchema.ID>,
|
||||
) {
|
||||
const child = yield* store.get(recovery.childSessionID)
|
||||
if (!child || child.parentID !== recovery.parentSessionID || !(yield* store.get(recovery.parentSessionID))) {
|
||||
yield* jobs.completeBackground(background.notificationID)
|
||||
return
|
||||
}
|
||||
|
||||
const notify = Effect.fnUntraced(function* (result: Pick<Job.Background, "status" | "output" | "error">) {
|
||||
if (result.status === "running") return
|
||||
const text =
|
||||
result.status === "completed"
|
||||
? (result.output ?? "Subagent completed without a text response.")
|
||||
: result.status === "error"
|
||||
? (result.error ?? "Subagent failed")
|
||||
: "Subagent cancelled"
|
||||
yield* sessions
|
||||
.synthetic({
|
||||
id: background.notificationID,
|
||||
sessionID: recovery.parentSessionID,
|
||||
...(suspended.has(recovery.parentSessionID) ? { resume: false } : {}),
|
||||
description: recovery.description,
|
||||
text: `<subagent sessionID="${recovery.childSessionID}" state="${result.status}" description="${recovery.description}">\n${text}\n</subagent>`,
|
||||
metadata: {
|
||||
source: "subagent",
|
||||
childID: recovery.childSessionID,
|
||||
agent: recovery.agent,
|
||||
state: result.status,
|
||||
},
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
yield* jobs.completeBackground(background.notificationID)
|
||||
})
|
||||
|
||||
if (background.status !== "running") {
|
||||
yield* notify(background)
|
||||
return
|
||||
}
|
||||
if ((yield* execution.active).has(recovery.childSessionID)) return
|
||||
if (!(yield* prepareResume(recovery.childSessionID))) {
|
||||
yield* notify({ status: "error", error: RESUME_EXHAUSTED.message })
|
||||
return
|
||||
}
|
||||
|
||||
yield* jobs.start({
|
||||
id: background.id,
|
||||
type: "subagent",
|
||||
title: recovery.description,
|
||||
notificationID: background.notificationID,
|
||||
recovery,
|
||||
run: execution.resume(recovery.childSessionID).pipe(
|
||||
Effect.andThen(store.context(recovery.childSessionID)),
|
||||
Effect.map((messages) => {
|
||||
const assistant = messages.findLast(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
if (assistant?.type !== "assistant") return "Subagent completed without a text response."
|
||||
return (
|
||||
assistant.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("") || "Subagent completed without a text response."
|
||||
)
|
||||
}),
|
||||
),
|
||||
})
|
||||
yield* jobs.background(background.id)
|
||||
yield* jobs.wait({ id: background.id }).pipe(
|
||||
Effect.flatMap((result) => (result.info ? notify(result.info) : Effect.void)),
|
||||
Effect.ignore,
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
// Forked into the service scope so boot never waits on resumed turns;
|
||||
// resuming an already-live Session joins its execution. Drain failures
|
||||
// are logged and durably recorded by the execution layer.
|
||||
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
resumeSuspendedSessions: Effect.gen(function* () {
|
||||
// Child claims never drive recovery (children are not resumed), so a
|
||||
// dead child's claim is noise no terminal will ever release. Clearing
|
||||
// is safe even against a live child: claims are recovery markers, not
|
||||
// locks, and children are excluded from that recovery.
|
||||
yield* store.releaseChildClaims
|
||||
const active = yield* execution.active
|
||||
// Early notices wait for root recovery's accounting, including roots that exhaust their budget.
|
||||
const suspended = new Set((yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID)))
|
||||
const pending = yield* jobs.pendingBackground
|
||||
yield* store.releaseChildClaims(
|
||||
pending.flatMap((background) =>
|
||||
background.status === "running" && background.recovery.kind === "subagent"
|
||||
? [background.recovery.childSessionID]
|
||||
: [],
|
||||
),
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
pending,
|
||||
Effect.fnUntraced(function* (background) {
|
||||
if ((yield* jobs.get(background.id))?.status === "running") return
|
||||
const recovery = background.recovery
|
||||
yield* recovery.kind === "shell"
|
||||
? recoverShell(background, recovery)
|
||||
: recoverSubagent(background, recovery, suspended)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
// Background completion can wake a parent, so inspect local ownership only after recovery.
|
||||
const resumed = yield* execution.active
|
||||
yield* Effect.forEach(
|
||||
(yield* store.listSuspended()).filter((sessionID) => !resumed.has(sessionID)),
|
||||
(sessionID) =>
|
||||
execution
|
||||
.resume(sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope), Effect.when(prepareResume(sessionID))),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
// Async observers consult this set at delivery; later completions wake parents normally.
|
||||
suspended.clear()
|
||||
// Sessions already draining in this process keep their claim; resuming
|
||||
// them would only inject a stray continuation into a live turn.
|
||||
const orphaned = (yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID))
|
||||
yield* Effect.forEach(orphaned, resumeOne, { concurrency: "unbounded", discard: true })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
@@ -253,5 +111,5 @@ export const layer = (options?: Options) =>
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node, Job.node, Session.node],
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node],
|
||||
})
|
||||
|
||||
@@ -78,143 +78,83 @@ const layer = Layer.effect(
|
||||
readonly continuation?: Continuation
|
||||
readonly promotable?: SessionInbox.Promotable
|
||||
}) {
|
||||
const sessionID = input.sessionID
|
||||
let force = input.force
|
||||
let continuing = input.continuation !== undefined
|
||||
let step = input.continuation?.step ?? 1
|
||||
let entering = true
|
||||
let continuation = input.continuation
|
||||
const promotable = input.promotable ?? "input"
|
||||
if (!force && !continuing) {
|
||||
const pending = yield* SessionInbox.nextPromotable(db, sessionID, "input")
|
||||
if (
|
||||
!pending ||
|
||||
(pending.delivery === "queue" &&
|
||||
promotable === "steer" &&
|
||||
pending.type !== "compaction" &&
|
||||
pending.type !== "move")
|
||||
)
|
||||
return DrainResult.Complete()
|
||||
}
|
||||
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable))) return DrainResult.Complete()
|
||||
yield* plugins.flush
|
||||
yield* settleStaleToolCalls(sessionID)
|
||||
|
||||
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
// Location entry and idle boundaries allow queued controls, not necessarily queued prompts.
|
||||
const pending = yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const next = yield* SessionInbox.nextPromotable(
|
||||
db,
|
||||
sessionID,
|
||||
entering || !continuing ? "input" : "steer",
|
||||
)
|
||||
if (next?.type === "compaction")
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: next.id }],
|
||||
[SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID: next.id }],
|
||||
])
|
||||
if (next?.type === "move")
|
||||
yield* restore(
|
||||
Effect.gen(function* () {
|
||||
yield* modelTransport.close(sessionID)
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: next.id }],
|
||||
[SessionEvent.Moved, { sessionID, ...next.payload }],
|
||||
])
|
||||
}),
|
||||
)
|
||||
return next
|
||||
}),
|
||||
)
|
||||
if (!continuing && pending?.delivery !== "steer") {
|
||||
entering = true
|
||||
step = 1
|
||||
}
|
||||
if (pending?.type === "move")
|
||||
return DrainResult.Moved({ continuation: !entering && continuing ? { step } : undefined })
|
||||
if (pending?.type === "compaction") {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const compacted = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
return yield* compaction.compactManual({
|
||||
session,
|
||||
messages: yield* store.context(sessionID),
|
||||
inputID: pending.id,
|
||||
started: true,
|
||||
})
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
if (Exit.isFailure(compacted)) {
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: Cause.hasInterruptsOnly(compacted.cause)
|
||||
? { type: "aborted", message: "Compaction cancelled" }
|
||||
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
||||
inputID: pending.id,
|
||||
})
|
||||
return yield* Effect.failCause(compacted.cause)
|
||||
}
|
||||
force = false
|
||||
continue
|
||||
}
|
||||
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
|
||||
return DrainResult.Complete()
|
||||
return yield* restore(
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* prepareContext(sessionID)
|
||||
const promoted = yield* SessionInbox.promote(
|
||||
db,
|
||||
bus,
|
||||
sessionID,
|
||||
entering && !continuing ? promotable : "steer",
|
||||
)
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
if (promoted > 0) step = 1
|
||||
return { _tag: "Ready" as const, context: yield* context.load(selected) }
|
||||
}),
|
||||
)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* settleStaleToolCalls(input.sessionID)
|
||||
while (true) {
|
||||
const next = yield* advanceToStep()
|
||||
if (next._tag !== "Ready") return next
|
||||
continuing = yield* runStep(next.context, step)
|
||||
step++
|
||||
// Scope gates input promotion, not a between-step control that is next in line.
|
||||
if (yield* runPendingCompaction(input.sessionID, "input")) {
|
||||
force = false
|
||||
continue
|
||||
}
|
||||
if (yield* runPendingMove(input.sessionID, "input")) return DrainResult.Moved({})
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
return DrainResult.Complete()
|
||||
const result = yield* runSteps(input.sessionID, continuation, promotable)
|
||||
if (result._tag === "Moved") return result
|
||||
force = false
|
||||
entering = false
|
||||
continuation = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const prepareContext = Effect.fn("SessionRunner.prepareContext")(function* (sessionID: SessionSchema.ID) {
|
||||
const selected = yield* context.select(sessionID)
|
||||
// A blocked initial instruction baseline must leave admitted input pending.
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, sessionID)
|
||||
return selected
|
||||
const eligible = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, promotable: SessionInbox.Promotable) {
|
||||
if (yield* SessionInbox.has(db, sessionID, promotable)) return true
|
||||
if (promotable === "input") return false
|
||||
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
|
||||
return next?.type === "compaction" || next?.type === "move"
|
||||
})
|
||||
|
||||
/** Queued inputs wait until the current model work reaches idle; later Steps absorb only steers. */
|
||||
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
continuation: Continuation | undefined,
|
||||
drainPromotable: SessionInbox.Promotable,
|
||||
) {
|
||||
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
|
||||
let step = continuation?.step ?? 1
|
||||
let next = continuation
|
||||
let first = true
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(sessionID, "steer")) continue
|
||||
if (yield* runPendingMove(sessionID, "steer")) return DrainResult.Moved({ continuation: next })
|
||||
if (!first && !next && !(yield* SessionInbox.has(db, sessionID, "steer"))) return DrainResult.Complete()
|
||||
const result = yield* runStep(sessionID, promotable, step)
|
||||
first = false
|
||||
promotable = "steer"
|
||||
step = result.step + 1
|
||||
next = result.needsContinuation ? { step } : undefined
|
||||
}
|
||||
})
|
||||
|
||||
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
|
||||
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
|
||||
const sessionID = first.session.id
|
||||
const runStep = Effect.fn("SessionRunner.runStep")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable,
|
||||
step: number,
|
||||
) {
|
||||
let assistantMessageID = SessionMessage.ID.create()
|
||||
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
|
||||
let initial: SessionContext.Loaded | undefined = first
|
||||
let currentPromotable: SessionInbox.Promotable | undefined = promotable
|
||||
let currentStep = step
|
||||
let recoverOverflow = true
|
||||
let recoverContinuation = true
|
||||
while (true) {
|
||||
// Reuse boundary preparation once; retries refresh context without delivering more input.
|
||||
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
|
||||
initial = undefined
|
||||
const selected = yield* context.select(sessionID)
|
||||
// A blocked initial instruction baseline must leave admitted input pending.
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
|
||||
const promoted = currentPromotable
|
||||
? yield* SessionInbox.promote(db, bus, selected.session.id, currentPromotable)
|
||||
: 0
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
currentStep = promoted > 0 ? 1 : currentStep
|
||||
currentPromotable = undefined
|
||||
const loaded = yield* context.load(selected)
|
||||
const compactionInput = { session: loaded.session, messages: loaded.messages, resolved: loaded.model }
|
||||
if (compaction.required(compactionInput)) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
@@ -222,7 +162,7 @@ const layer = Layer.effect(
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
continue
|
||||
}
|
||||
const stepLimitReached = loaded.agent.info.steps !== undefined && step >= loaded.agent.info.steps
|
||||
const stepLimitReached = loaded.agent.info.steps !== undefined && currentStep >= loaded.agent.info.steps
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: loaded.agent.info,
|
||||
model: loaded.model,
|
||||
@@ -257,7 +197,7 @@ const layer = Layer.effect(
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
})
|
||||
if (outcome._tag === "Completed") return outcome.needsContinuation
|
||||
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: currentStep }
|
||||
if (outcome._tag === "Retry" || outcome._tag === "Continue") {
|
||||
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
|
||||
Pull.catchDone(() =>
|
||||
@@ -283,6 +223,77 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable,
|
||||
) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||
if (selected?.type !== "compaction") return
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
|
||||
[SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID: selected.id }],
|
||||
])
|
||||
return selected
|
||||
}),
|
||||
)
|
||||
if (pending?.type !== "compaction") return false
|
||||
const session = yield* getSession(sessionID)
|
||||
const compacted = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
return yield* compaction.compactManual({
|
||||
session,
|
||||
messages: yield* store.context(sessionID),
|
||||
inputID: pending.id,
|
||||
started: true,
|
||||
})
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(compacted)) return true
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: Cause.hasInterruptsOnly(compacted.cause)
|
||||
? { type: "aborted", message: "Compaction cancelled" }
|
||||
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
||||
inputID: pending.id,
|
||||
})
|
||||
return yield* Effect.failCause(compacted.cause)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const runPendingMove = Effect.fn("SessionRunner.runPendingMove")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable,
|
||||
) {
|
||||
return yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||
if (pending?.type !== "move") return false
|
||||
yield* modelTransport.close(sessionID)
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
|
||||
[
|
||||
SessionEvent.Moved,
|
||||
{
|
||||
sessionID,
|
||||
location: pending.payload.location,
|
||||
projectID: pending.payload.projectID,
|
||||
subpath: pending.payload.subpath,
|
||||
},
|
||||
],
|
||||
])
|
||||
return true
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
@@ -290,24 +301,23 @@ const layer = Layer.effect(
|
||||
if (message.type !== "assistant") continue
|
||||
for (const tool of message.content) {
|
||||
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
|
||||
const metadata = tool.state.status === "running" ? tool.state.metadata : undefined
|
||||
const childID =
|
||||
tool.name === "subagent" && typeof metadata?.sessionID === "string" ? metadata.sessionID : undefined
|
||||
yield* bus.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: message.id,
|
||||
id: tool.id,
|
||||
error: {
|
||||
type: "aborted",
|
||||
message: `Tool execution interrupted: ${tool.name}${childID ? ` (sessionID: ${childID})` : ""}`,
|
||||
},
|
||||
...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}),
|
||||
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
|
||||
executed: tool.executed === true,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
return session
|
||||
})
|
||||
|
||||
return Service.of({ drain })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -327,10 +327,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
id,
|
||||
error:
|
||||
tool.name === "subagent" && error.type === "aborted" && typeof tool.progress?.sessionID === "string"
|
||||
? { ...error, message: `${error.message} (sessionID: ${tool.progress.sessionID})` }
|
||||
: error,
|
||||
error,
|
||||
...failureSnapshot(tool, metadata),
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionStore from "./store.js"
|
||||
|
||||
import { and, eq, isNotNull, isNull, notInArray, sql } from "drizzle-orm"
|
||||
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -18,8 +18,9 @@ export interface Interface {
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
|
||||
/**
|
||||
* Top-level Sessions holding an execution claim. Recoverable background
|
||||
* children are resumed separately through their durable Job records.
|
||||
* Top-level Sessions holding an execution claim. Child (subagent) Sessions
|
||||
* are excluded: a resumed parent re-runs its tool call and spawns fresh
|
||||
* children, so resuming orphaned children would duplicate their work.
|
||||
*/
|
||||
readonly listSuspended: () => Effect.Effect<ReadonlyArray<Session.ID>>
|
||||
/**
|
||||
@@ -32,10 +33,11 @@ export interface Interface {
|
||||
/** Releases the claim and resets resume accounting. Terminal events call this on commit. */
|
||||
readonly release: (sessionID: Session.ID) => Effect.Effect<void>
|
||||
/**
|
||||
* Clears orphaned child claims except children owned by recoverable
|
||||
* background subagent jobs.
|
||||
* Clears orphaned child (subagent) claims. Children are never resumed
|
||||
* independently, so a dead child's claim is noise no terminal will ever
|
||||
* release.
|
||||
*/
|
||||
readonly releaseChildClaims: (recoverable: ReadonlyArray<Session.ID>) => Effect.Effect<void>
|
||||
readonly releaseChildClaims: Effect.Effect<void>
|
||||
/**
|
||||
* Durably counts one more resume of an orphaned claim, returning the new
|
||||
* total — or undefined when the Session no longer exists.
|
||||
@@ -101,20 +103,12 @@ const layer = Layer.effect(
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
releaseChildClaims: Effect.fn("SessionStore.releaseChildClaims")((recoverable) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(
|
||||
and(
|
||||
isNotNull(SessionTable.time_suspended),
|
||||
isNotNull(SessionTable.parent_id),
|
||||
recoverable.length > 0 ? notInArray(SessionTable.id, Array.from(recoverable)) : undefined,
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid),
|
||||
),
|
||||
releaseChildClaims: db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(and(isNotNull(SessionTable.time_suspended), isNotNull(SessionTable.parent_id)))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid, Effect.withSpan("SessionStore.releaseChildClaims")),
|
||||
countResume: Effect.fn("SessionStore.countResume")(function* (sessionID) {
|
||||
const row = yield* db
|
||||
.update(SessionTable)
|
||||
|
||||
@@ -134,8 +134,8 @@ const layer = () =>
|
||||
Effect.gen(function* () {
|
||||
for (const session of sessions.values()) {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Teardown interrupts pending commands; it is not a terminal command failure.
|
||||
yield* Deferred.interrupt(session.done)
|
||||
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
|
||||
}
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
|
||||
+33
-30
@@ -25,7 +25,7 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
|
||||
export interface Interface {
|
||||
readonly transform: (
|
||||
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
|
||||
) => Effect.Effect<void, never, Scope.Scope>
|
||||
) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
|
||||
}
|
||||
|
||||
@@ -140,35 +140,45 @@ const layer = Layer.effect(
|
||||
const transform: Interface["transform"] = Effect.fn("Tool.transform")(function* (callback) {
|
||||
const tools: Array<Tool.Info> = []
|
||||
yield* Effect.sync(() => callback({ add: (tool) => tools.push(tool) }))
|
||||
const valid = yield* Effect.filter(normalizedEntries(tools), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
if (entry.tool.options?.namespace !== undefined) yield* validateNamespace(entry.tool.options.namespace)
|
||||
yield* validateName(normalizedName(entry.tool))
|
||||
if (entry.tool.options?.codemode === false && entry.key === "execute")
|
||||
return yield* new RegistrationError({
|
||||
name: entry.key,
|
||||
message: 'Tool name "execute" is reserved for CodeMode',
|
||||
})
|
||||
yield* Effect.try({
|
||||
yield* Effect.forEach(
|
||||
tools.flatMap((tool) => (tool.options?.namespace === undefined ? [] : [tool.options.namespace])),
|
||||
validateNamespace,
|
||||
{ discard: true },
|
||||
)
|
||||
const entries = normalizedEntries(tools)
|
||||
yield* Effect.forEach(entries, (entry) => validateName(normalizedName(entry.tool)), { discard: true })
|
||||
const collision = entries.find(
|
||||
(entry, index) => entries.findIndex((candidate) => candidate.key === entry.key) !== index,
|
||||
)
|
||||
if (collision)
|
||||
return yield* Effect.fail(
|
||||
new RegistrationError({
|
||||
name: collision.key,
|
||||
message: `Duplicate normalized tool name: ${collision.key}`,
|
||||
}),
|
||||
)
|
||||
const reserved = entries.find((entry) => entry.tool.options?.codemode === false && entry.key === "execute")
|
||||
if (reserved)
|
||||
return yield* Effect.fail(
|
||||
new RegistrationError({
|
||||
name: reserved.key,
|
||||
message: 'Tool name "execute" is reserved for CodeMode',
|
||||
}),
|
||||
)
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.forEach(
|
||||
entries,
|
||||
(entry) =>
|
||||
Effect.try({
|
||||
try: () => ToolDefinition.make(definition(entry.tool)),
|
||||
catch: (error) =>
|
||||
new RegistrationError({
|
||||
name: entry.key,
|
||||
message: `Invalid tool definition ${entry.key}: ${schemaMakeError(error)}`,
|
||||
}),
|
||||
})
|
||||
return true
|
||||
}).pipe(Effect.catchTag("Tool.RegistrationError", (error) => skipRegistration(entry.tool, error))),
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
// Reject every ambiguous entry rather than choosing a winner.
|
||||
const entries = yield* Effect.filter(valid, (entry) => {
|
||||
if (!valid.some((candidate) => candidate !== entry && candidate.key === entry.key)) return Effect.succeed(true)
|
||||
return skipRegistration(
|
||||
entry.tool,
|
||||
new RegistrationError({ name: entry.key, message: `Duplicate normalized tool name: ${entry.key}` }),
|
||||
)
|
||||
})
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.uninterruptible(
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
@@ -260,13 +270,6 @@ function schemaMakeError(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
const skipRegistration = (tool: Tool.Info, error: RegistrationError) =>
|
||||
Effect.logError("Skipping invalid tool registration", {
|
||||
name: tool.name,
|
||||
namespace: tool.options?.namespace,
|
||||
error: error.message,
|
||||
}).pipe(Effect.as(false))
|
||||
|
||||
const validateName = (name: string) =>
|
||||
/^[A-Za-z0-9_-]{1,64}$/.test(name)
|
||||
? Effect.void
|
||||
|
||||
@@ -115,7 +115,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
})
|
||||
.pipe(Scope.provide(next))
|
||||
.pipe(Scope.provide(next), Effect.orDie)
|
||||
if (current) yield* Scope.close(current, Exit.void)
|
||||
current = next
|
||||
}),
|
||||
|
||||
@@ -115,45 +115,56 @@ export const Plugin = {
|
||||
const permission = yield* Permission.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(
|
||||
function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
id: string,
|
||||
shellID: string,
|
||||
command: string,
|
||||
settled: Deferred.Deferred<Output>,
|
||||
) {
|
||||
const info = (yield* runtime.job.wait({ id })).info
|
||||
if (!info || info.status === "running") return
|
||||
const output = info.status === "completed" ? yield* Deferred.await(settled) : undefined
|
||||
const text = output
|
||||
? resultMessages(output).join("\n\n")
|
||||
: info.status === "error"
|
||||
? (info.error ?? "Command failed")
|
||||
: "Command cancelled"
|
||||
yield* runtime.session.synthetic({
|
||||
...(info.notificationID ? { id: info.notificationID } : {}),
|
||||
sessionID,
|
||||
text: `<shell id="${id}" state="${info.status}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: id,
|
||||
shellID,
|
||||
state: info.status,
|
||||
...(output
|
||||
? {
|
||||
truncated: output.truncated,
|
||||
...(output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
|
||||
},
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
id: string,
|
||||
shellID: string,
|
||||
command: string,
|
||||
settled: Deferred.Deferred<Output>,
|
||||
) {
|
||||
yield* runtime.job.wait({ id: id }).pipe(
|
||||
Effect.flatMap((result) =>
|
||||
Effect.gen(function* () {
|
||||
const info = result.info
|
||||
if (!info) return
|
||||
const state =
|
||||
info.status === "completed"
|
||||
? "completed"
|
||||
: info.status === "error"
|
||||
? "error"
|
||||
: info.status === "cancelled"
|
||||
? "cancelled"
|
||||
: undefined
|
||||
if (state === undefined) return
|
||||
const output = state === "completed" ? yield* Deferred.await(settled) : undefined
|
||||
const text = output
|
||||
? resultMessages(output).join("\n\n")
|
||||
: state === "error"
|
||||
? (info.error ?? "Command failed")
|
||||
: "Command cancelled"
|
||||
yield* runtime.session.synthetic({
|
||||
sessionID,
|
||||
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: id,
|
||||
shellID,
|
||||
state,
|
||||
...(output
|
||||
? {
|
||||
truncated: output.truncated,
|
||||
...(output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
@@ -275,7 +286,7 @@ export const Plugin = {
|
||||
const settled = yield* Deferred.make<Output>()
|
||||
const run = settleShell().pipe(
|
||||
Effect.tap((output) => Deferred.succeed(settled, output)),
|
||||
Effect.map((output) => resultMessages(output).join("\n\n")),
|
||||
Effect.map((output) => output.output),
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const job = yield* runtime.job.start({
|
||||
@@ -283,12 +294,6 @@ export const Plugin = {
|
||||
type: name,
|
||||
title: info.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
sessionID: context.sessionID,
|
||||
shellID: info.id,
|
||||
command: info.command,
|
||||
},
|
||||
run,
|
||||
})
|
||||
|
||||
|
||||
@@ -78,6 +78,22 @@ export const Plugin = {
|
||||
return text.length > 0 ? text : NO_TEXT
|
||||
})
|
||||
|
||||
const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* (
|
||||
parentID: SessionSchema.ID,
|
||||
childID: SessionSchema.ID,
|
||||
agent: string,
|
||||
description: string,
|
||||
state: "completed" | "error" | "cancelled",
|
||||
text: string,
|
||||
) {
|
||||
yield* runtime.session.synthetic({
|
||||
sessionID: parentID,
|
||||
text: `<subagent sessionID="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||
description,
|
||||
metadata: { source: "subagent", childID, agent, state },
|
||||
})
|
||||
})
|
||||
|
||||
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
|
||||
parentID: SessionSchema.ID,
|
||||
childID: SessionSchema.ID,
|
||||
@@ -88,24 +104,23 @@ export const Plugin = {
|
||||
const key = `${childID}:${startedAt}`
|
||||
if (notifications.has(key)) return
|
||||
notifications.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
const info = (yield* runtime.job.wait({ id: childID })).info
|
||||
if (!info || info.status === "running") return
|
||||
const text =
|
||||
info.status === "completed"
|
||||
? (info.output ?? NO_TEXT)
|
||||
: info.status === "error"
|
||||
? (info.error ?? "Subagent failed")
|
||||
: "Subagent cancelled"
|
||||
yield* runtime.session.synthetic({
|
||||
...(info.notificationID ? { id: info.notificationID } : {}),
|
||||
sessionID: parentID,
|
||||
text: `<subagent sessionID="${childID}" state="${info.status}" description="${description}">\n${text}\n</subagent>`,
|
||||
description,
|
||||
metadata: { source: "subagent", childID, agent, state: info.status },
|
||||
})
|
||||
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
|
||||
}).pipe(
|
||||
yield* runtime.job.wait({ id: childID }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (result.info?.status === "completed")
|
||||
return injectCompletion(parentID, childID, agent, description, "completed", result.info.output ?? NO_TEXT)
|
||||
if (result.info?.status === "error")
|
||||
return injectCompletion(
|
||||
parentID,
|
||||
childID,
|
||||
agent,
|
||||
description,
|
||||
"error",
|
||||
result.info.error ?? "Subagent failed",
|
||||
)
|
||||
if (result.info?.status === "cancelled")
|
||||
return injectCompletion(parentID, childID, agent, description, "cancelled", "Subagent cancelled")
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
@@ -224,7 +239,6 @@ export const Plugin = {
|
||||
existing === undefined
|
||||
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
|
||||
: input.prompt,
|
||||
...(background && existing === undefined ? { resume: false } : {}),
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
@@ -232,19 +246,17 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
yield* runtime.session.resume(child.id)
|
||||
return yield* latestAssistantText(child.id)
|
||||
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
|
||||
|
||||
const info = yield* runtime.job.start({
|
||||
id: child.id,
|
||||
type: name,
|
||||
title: input.description,
|
||||
metadata: {},
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: context.sessionID,
|
||||
childSessionID: child.id,
|
||||
agent: agent.name,
|
||||
description: input.description,
|
||||
},
|
||||
run: runtime.session.resume(child.id).pipe(Effect.andThen(latestAssistantText(child.id))),
|
||||
run,
|
||||
})
|
||||
|
||||
if (background) {
|
||||
|
||||
@@ -385,9 +385,6 @@ describe("DatabaseMigration", () => {
|
||||
const content = JSON.stringify({
|
||||
openai: { type: "oauth", refresh: "refresh", access: "access", expires: 123, accountId: "account" },
|
||||
anthropic: { type: "api", key: "legacy-key", metadata: { region: "us" } },
|
||||
google: { type: "api", key: "google-key", metadata: { region: "us" } },
|
||||
"github-copilot": { type: "oauth", refresh: "refresh", access: "access", expires: 123 },
|
||||
"custom-provider": { type: "api", key: "custom-key" },
|
||||
"https://example.com/": { type: "wellknown", key: "TOKEN", token: "wellknown-key" },
|
||||
invalid: { type: "unknown" },
|
||||
})
|
||||
@@ -405,7 +402,6 @@ describe("DatabaseMigration", () => {
|
||||
|
||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
|
||||
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
|
||||
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
|
||||
[
|
||||
@@ -414,35 +410,14 @@ describe("DatabaseMigration", () => {
|
||||
label: "Existing",
|
||||
value: JSON.stringify({ type: "key", key: "current-key" }),
|
||||
},
|
||||
{
|
||||
integration_id: "custom-provider",
|
||||
label: "API key",
|
||||
value: JSON.stringify({ type: "key", key: "custom-key" }),
|
||||
},
|
||||
{
|
||||
integration_id: "github-copilot",
|
||||
label: "OAuth",
|
||||
value: JSON.stringify({
|
||||
type: "oauth",
|
||||
methodID: "device",
|
||||
refresh: "refresh",
|
||||
access: "access",
|
||||
expires: 123,
|
||||
}),
|
||||
},
|
||||
{
|
||||
integration_id: "google",
|
||||
label: "API key",
|
||||
value: JSON.stringify({ type: "key", key: "google-key", metadata: { region: "us" } }),
|
||||
},
|
||||
{
|
||||
integration_id: "https://example.com",
|
||||
label: "API key",
|
||||
label: "default",
|
||||
value: JSON.stringify({ type: "key", key: "wellknown-key" }),
|
||||
},
|
||||
{
|
||||
integration_id: "openai",
|
||||
label: "OAuth",
|
||||
label: "default",
|
||||
value: JSON.stringify({
|
||||
type: "oauth",
|
||||
methodID: "chatgpt-browser",
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Job.node, KV.node])))
|
||||
const it = testEffect(AppNodeBuilder.build(Job.node))
|
||||
|
||||
describe("Job", () => {
|
||||
it.live("tracks process-local work through explicit observation", () =>
|
||||
@@ -147,177 +145,6 @@ describe("Job", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retains background ownership and terminal output until notification acknowledgment", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const latch = yield* Deferred.make<void>()
|
||||
const recovery = {
|
||||
kind: "shell" as const,
|
||||
sessionID: SessionSchema.ID.make("ses_background_shell"),
|
||||
shellID: "shell_background",
|
||||
command: "echo done",
|
||||
}
|
||||
const job = yield* jobs.start({ type: "shell", recovery, run: Deferred.await(latch).pipe(Effect.as("done")) })
|
||||
|
||||
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toBeUndefined()
|
||||
const background = yield* jobs.background(job.id)
|
||||
|
||||
const running = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
|
||||
expect(running).toMatchObject({ id: job.id, recovery, status: "running" })
|
||||
expect(running?.notificationID).toStartWith("msg_")
|
||||
expect(background?.notificationID).toBe(running?.notificationID)
|
||||
|
||||
yield* Deferred.succeed(latch, undefined)
|
||||
yield* jobs.wait({ id: job.id })
|
||||
|
||||
const completed = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
|
||||
expect(completed).toMatchObject({
|
||||
id: job.id,
|
||||
notificationID: running?.notificationID,
|
||||
recovery,
|
||||
status: "completed",
|
||||
output: "done",
|
||||
})
|
||||
if (!completed) return yield* Effect.die("background marker missing")
|
||||
|
||||
yield* jobs.completeBackground(completed.notificationID)
|
||||
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("persists backgroundAll ownership before releasing a blocked subagent", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const parentSessionID = SessionSchema.ID.make("ses_background_parent")
|
||||
const latch = yield* Deferred.make<void>()
|
||||
const recovery = {
|
||||
kind: "subagent" as const,
|
||||
parentSessionID,
|
||||
childSessionID: SessionSchema.ID.make("ses_background_child"),
|
||||
agent: "explore",
|
||||
description: "Explore background recovery",
|
||||
}
|
||||
const job = yield* jobs.start({ type: "subagent", recovery, run: Deferred.await(latch).pipe(Effect.as("done")) })
|
||||
const waiting = yield* jobs
|
||||
.block({ id: job.id, sessionID: parentSessionID })
|
||||
.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
|
||||
|
||||
yield* jobs.backgroundAll({ sessionID: parentSessionID })
|
||||
expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } })
|
||||
|
||||
const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
|
||||
expect(marker).toMatchObject({ id: job.id, recovery, status: "running" })
|
||||
if (!marker) return yield* Effect.die("background marker missing")
|
||||
|
||||
yield* jobs.cancel(job.id)
|
||||
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toMatchObject({
|
||||
notificationID: marker.notificationID,
|
||||
status: "cancelled",
|
||||
})
|
||||
yield* jobs.completeBackground(marker.notificationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retains terminal errors for recovery until notification acknowledgment", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const latch = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
type: "shell",
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
sessionID: SessionSchema.ID.make("ses_background_error"),
|
||||
shellID: "shell_error",
|
||||
command: "exit 1",
|
||||
},
|
||||
run: Deferred.await(latch).pipe(Effect.andThen(Effect.fail(new Error("shell failed")))),
|
||||
})
|
||||
|
||||
yield* jobs.background(job.id)
|
||||
yield* Deferred.succeed(latch, undefined)
|
||||
yield* jobs.wait({ id: job.id })
|
||||
|
||||
const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
|
||||
expect(marker).toMatchObject({ id: job.id, status: "error", error: "shell failed" })
|
||||
if (!marker) return yield* Effect.die("background marker missing")
|
||||
yield* jobs.completeBackground(marker.notificationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("durably backgrounds recoverable work that has already failed", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const job = yield* jobs.start({
|
||||
type: "shell",
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
sessionID: SessionSchema.ID.make("ses_immediate_error"),
|
||||
shellID: "shell_immediate_error",
|
||||
command: "exit 1",
|
||||
},
|
||||
run: Effect.fail(new Error("shell failed")),
|
||||
})
|
||||
expect((yield* jobs.wait({ id: job.id })).info?.status).toBe("error")
|
||||
|
||||
const background = yield* jobs.background(job.id)
|
||||
expect(background?.notificationID).toStartWith("msg_")
|
||||
expect(yield* jobs.pendingBackground).toMatchObject([
|
||||
{ id: job.id, notificationID: background?.notificationID, status: "error", error: "shell failed" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("recovers a background marker after its process-local registry closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make()
|
||||
const previous = yield* Job.make.pipe(Scope.provide(scope))
|
||||
const job = yield* previous.start({
|
||||
type: "shell",
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
sessionID: SessionSchema.ID.make("ses_background_restart"),
|
||||
shellID: "shell_restart",
|
||||
command: "sleep 60",
|
||||
},
|
||||
run: Effect.never,
|
||||
})
|
||||
yield* previous.background(job.id)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
const current = yield* Job.make
|
||||
const marker = (yield* current.pendingBackground).find((item) => item.id === job.id)
|
||||
expect(marker).toMatchObject({ id: job.id, status: "running" })
|
||||
if (!marker) return yield* Effect.die("background marker missing")
|
||||
yield* current.completeBackground(marker.notificationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves running background ownership when its work is interrupted", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: SessionSchema.ID.make("ses_interrupted_parent"),
|
||||
childSessionID: SessionSchema.ID.make("ses_interrupted_child"),
|
||||
agent: "explore",
|
||||
description: "Continue after shutdown",
|
||||
},
|
||||
run: Deferred.await(interrupted).pipe(Effect.andThen(Effect.interrupt)),
|
||||
})
|
||||
yield* jobs.background(job.id)
|
||||
yield* Deferred.succeed(interrupted, undefined)
|
||||
yield* jobs.wait({ id: job.id })
|
||||
|
||||
const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
|
||||
expect(marker).toMatchObject({ id: job.id, status: "running" })
|
||||
if (!marker) return yield* Effect.die("background marker missing")
|
||||
yield* jobs.completeBackground(marker.notificationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
@@ -35,7 +35,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
|
||||
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Schedule, Schema, Sink, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
@@ -77,7 +77,6 @@ function resourceServer(
|
||||
templateLists: 0,
|
||||
toolLists: 0,
|
||||
initializations: 0,
|
||||
urls: [] as string[],
|
||||
}
|
||||
const protocol = new Server(
|
||||
{ name: "mcp-resources", version: "1.0.0" },
|
||||
@@ -146,7 +145,6 @@ function resourceServer(
|
||||
const http = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
state.urls.push(request.url)
|
||||
const body: unknown = request.method === "POST" ? await request.clone().json() : undefined
|
||||
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
|
||||
state.initializations += 1
|
||||
@@ -720,40 +718,6 @@ test("applies configured MCP timeouts to resource operations", async () => {
|
||||
await expect(read).rejects.toThrow("Request timed out")
|
||||
})
|
||||
|
||||
for (const entry of [
|
||||
{ name: "default", query: "", codemode: undefined, expected: "?codemode=false" },
|
||||
{ name: "explicit local code mode", query: "", codemode: true, expected: "?codemode=false" },
|
||||
{ name: "direct tools", query: "", codemode: false, expected: "" },
|
||||
{
|
||||
name: "existing query",
|
||||
query: "?source=opencode",
|
||||
codemode: undefined,
|
||||
expected: "?source=opencode&codemode=false",
|
||||
},
|
||||
{ name: "explicit remote code mode", query: "?codemode=true", codemode: undefined, expected: "?codemode=true" },
|
||||
{ name: "explicit remote opt-out", query: "?codemode=false", codemode: undefined, expected: "?codemode=false" },
|
||||
{ name: "portal opt-out", query: "?codemode=off", codemode: undefined, expected: "?codemode=off" },
|
||||
]) {
|
||||
testEffect(Layer.empty).live(`remote MCP code mode preference: ${entry.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer()
|
||||
const config = new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
url: server.url + entry.query,
|
||||
oauth: false,
|
||||
codemode: entry.codemode,
|
||||
})
|
||||
const connection = yield* connect("resources", config, import.meta.dir)
|
||||
yield* connection.tools()
|
||||
expect(server.state.initializations).toBe(1)
|
||||
expect(server.state.toolLists).toBe(1)
|
||||
expect(server.state.urls.length).toBeGreaterThanOrEqual(3)
|
||||
expect(new Set(server.state.urls)).toEqual(new Set([server.url + entry.expected]))
|
||||
expect(config.url).toBe(server.url + entry.query)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("lists, reads, and reports MCP resource changes", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
@@ -1229,86 +1193,6 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updates alive", () =>
|
||||
Effect.gen(function* () {
|
||||
const tool = (server: string, name: string) =>
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make(server),
|
||||
name,
|
||||
codemode: false,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})
|
||||
const healthy = [tool("demo", "search"), tool("other", "lookup")]
|
||||
const namespace = tool("x".repeat(65), "lookup")
|
||||
const catalog = yield* Ref.make([tool("demo", "x".repeat(65)), ...healthy, namespace])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* registration.flush
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_search",
|
||||
"other_lookup",
|
||||
"execute",
|
||||
])
|
||||
|
||||
yield* Ref.set(catalog, [tool("demo", "y".repeat(65)), ...healthy, tool("demo", "added"), namespace])
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
yield* waitForTool(registry, "demo_added")
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_added",
|
||||
"demo_search",
|
||||
"other_lookup",
|
||||
"execute",
|
||||
])
|
||||
yield* Effect.forEach(["demo_search", "other_lookup"], (name) =>
|
||||
executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
|
||||
}).pipe(Effect.tap((result) => Effect.sync(() => expect(result).toMatchObject({ status: "completed" })))),
|
||||
)
|
||||
|
||||
yield* Ref.set(catalog, [tool("demo", "status"), ...healthy, tool("demo", "added"), tool("repaired", "lookup")])
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
yield* waitForTool(registry, "demo_status")
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_added",
|
||||
"demo_search",
|
||||
"demo_status",
|
||||
"other_lookup",
|
||||
"repaired_lookup",
|
||||
"execute",
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
[
|
||||
MCP.node,
|
||||
Layer.mock(MCP.Service, {
|
||||
tools: () => Ref.get(catalog),
|
||||
callTool: (input) =>
|
||||
Effect.succeed(
|
||||
new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "healthy" }],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
],
|
||||
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
|
||||
[Image.node, imagePassthrough],
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
@@ -1126,7 +1126,7 @@ describe("ModelResolver", () => {
|
||||
const mantle = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
modelID: "openai.gpt-oss-120b",
|
||||
settings: { region: "us-east-1", topP: 0.6 },
|
||||
settings: { region: "us-east-1" },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1158,7 +1158,6 @@ describe("ModelResolver", () => {
|
||||
expect(bedrock.route.defaults.generation).toEqual({ topP: 0.8 })
|
||||
expect(bedrock.route.defaults.http?.body).toEqual({ serviceTier: { type: "priority" } })
|
||||
expect(mantle.route.id).toBe("bedrock-mantle-responses")
|
||||
expect(mantle.route.defaults.generation).toEqual({ topP: 0.6 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import path from "path"
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, FileSystem, Layer } from "effect"
|
||||
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const source = "https://models.opencode.ai"
|
||||
const it = testEffect(
|
||||
LayerNode.compile(LayerNode.group([ModelsDevCache.node, LayerNodePlatform.filesystem, Global.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
it.live("returns undefined for a missing catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
expect(yield* cache.read(source)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("persists raw catalog bodies larger than 2 MB with the file mtime", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const body = ` {\n "payload": "${"x".repeat(2 * 1024 * 1024)}"\n}\n`
|
||||
const file = path.join(global.cache, "models-dev", `${Hash.fast(source)}.json`)
|
||||
const modified = new Date("2026-01-01T00:00:00Z")
|
||||
|
||||
yield* cache.write(source, body)
|
||||
expect(yield* fs.readFileString(file)).toBe(body)
|
||||
yield* fs.utimes(file, modified, modified)
|
||||
expect(yield* cache.read(source)).toEqual({ body, updatedAt: modified.getTime() })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("isolates catalogs by source including the default source", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const custom = "https://models.example.com"
|
||||
|
||||
yield* cache.write(source, "default catalog")
|
||||
expect(yield* cache.read(custom)).toBeUndefined()
|
||||
yield* cache.write(custom, "custom catalog")
|
||||
expect((yield* cache.read(source))?.body).toBe("default catalog")
|
||||
expect((yield* cache.read(custom))?.body).toBe("custom catalog")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("replaces an existing catalog without leaving temporary files", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
|
||||
yield* cache.write(source, "old catalog")
|
||||
yield* cache.write(source, "new catalog")
|
||||
expect((yield* cache.read(source))?.body).toBe("new catalog")
|
||||
expect(yield* fs.readDirectory(path.join(global.cache, "models-dev"))).toEqual([`${Hash.fast(source)}.json`])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("cleans up temporary files and preserves platform errors when replacement fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.cache, "models-dev")
|
||||
const file = path.join(directory, `${Hash.fast(source)}.json`)
|
||||
yield* fs.makeDirectory(file, { recursive: true })
|
||||
|
||||
const error = yield* cache.write(source, "new catalog").pipe(Effect.flip)
|
||||
expect(error._tag).toBe("PlatformError")
|
||||
expect(yield* fs.readDirectory(directory)).toEqual([`${Hash.fast(source)}.json`])
|
||||
expect((yield* fs.stat(file)).type).toBe("Directory")
|
||||
expect((yield* cache.read(source).pipe(Effect.flip))._tag).toBe("PlatformError")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps the old catalog readable and cleans up an interrupted replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const staged = yield* Deferred.make<string>()
|
||||
yield* cache.write(source, "old catalog")
|
||||
|
||||
// Pause only the commit; staging and cleanup still use the real filesystem.
|
||||
const writer = yield* ModelsDevCache.Service.pipe(
|
||||
Effect.flatMap((service) => service.write(source, "new catalog")),
|
||||
Effect.provide(Layer.fresh(ModelsDevCache.layer)),
|
||||
Effect.provideService(FileSystem.FileSystem, {
|
||||
...fs,
|
||||
rename: (file) => Deferred.succeed(staged, file).pipe(Effect.andThen(Effect.never)),
|
||||
}),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const temporary = yield* Deferred.await(staged)
|
||||
expect(yield* fs.readFileString(temporary)).toBe("new catalog")
|
||||
expect((yield* cache.read(source))?.body).toBe("old catalog")
|
||||
|
||||
yield* Fiber.interrupt(writer)
|
||||
expect((yield* cache.read(source))?.body).toBe("old catalog")
|
||||
expect(yield* fs.readDirectory(path.join(global.cache, "models-dev"))).toEqual([`${Hash.fast(source)}.json`])
|
||||
}),
|
||||
)
|
||||
@@ -1,18 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { bodyDigest, ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const cacheKey = "models-dev:catalog"
|
||||
const source = "https://models.opencode.ai"
|
||||
|
||||
test("normalizes permissive interleaved values to compatibility", () => {
|
||||
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
|
||||
@@ -166,41 +168,40 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
|
||||
)
|
||||
|
||||
interface MockCache {
|
||||
readonly values: Map<string, KV.Value>
|
||||
readonly values: Map<string, ModelsDevCache.Entry>
|
||||
}
|
||||
|
||||
const makeMockKV = (cache: MockCache) =>
|
||||
Layer.mock(KV.Service, {
|
||||
get: (key) => Effect.sync(() => cache.values.get(key)),
|
||||
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
|
||||
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
|
||||
const makeMockCache = (cache: MockCache) =>
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: (source) => Effect.sync(() => cache.values.get(source)),
|
||||
write: (source, body) =>
|
||||
Effect.sync(() => cache.values.set(source, { updatedAt: Date.now(), body })).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
|
||||
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
|
||||
// and Effect.provide uses a process-global MemoMap by default — without fresh,
|
||||
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
||||
const buildLayer = (
|
||||
state: Ref.Ref<MockState>,
|
||||
cache: MockCache,
|
||||
options: ModelsDev.Options = { fetch: false },
|
||||
persistence = makeMockCache(cache),
|
||||
) =>
|
||||
Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([ModelsDev.node, Bus.node]), [
|
||||
[ModelsDev.node, ModelsDev.configured(options)],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeMockKV(cache)],
|
||||
[ModelsDevCache.node, persistence],
|
||||
]),
|
||||
)
|
||||
|
||||
// Mirrors production KV backends whose writes die as defects (e.g. Durable
|
||||
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
|
||||
const makeFailingWriteKV = (cache: MockCache) =>
|
||||
Layer.mock(KV.Service, {
|
||||
get: (key) => Effect.sync(() => cache.values.get(key)),
|
||||
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
|
||||
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
|
||||
const makeFailingWriteCache = (cache: MockCache) =>
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: (source) => Effect.sync(() => cache.values.get(source)),
|
||||
write: () => Effect.die(new Error("Cache write failed")),
|
||||
})
|
||||
|
||||
const makeCache = (): MockCache => ({ values: new Map() })
|
||||
|
||||
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
|
||||
cache.values.set(cacheKey, { updatedAt, digest: bodyDigest(text), body: text })
|
||||
cache.values.set(source, { updatedAt, body: text })
|
||||
|
||||
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
|
||||
writeCacheText(cache, JSON.stringify(data), updatedAt)
|
||||
@@ -218,7 +219,7 @@ const initialState: MockState = {
|
||||
}
|
||||
|
||||
describe("ModelsDev Service", () => {
|
||||
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
|
||||
it.live("get() returns normalized snapshots from the persisted cache", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
@@ -259,7 +260,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
|
||||
it.live("get() returns empty catalog when the cache, fetch, and bundled snapshot are unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
@@ -272,7 +273,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() falls back to the bundled snapshot when KV is empty and fetch is disabled", () =>
|
||||
it.live("get() falls back to the bundled snapshot when the cache is empty and fetch is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
@@ -289,7 +290,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
|
||||
it.live("get() recovers from a corrupted cache by fetching a fresh catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCacheText(cache, "{")
|
||||
@@ -297,31 +298,247 @@ describe("ModelsDev Service", () => {
|
||||
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
expect(cache.values.get(source)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() still populates the catalog when the KV cache write fails", () =>
|
||||
it.live("get() still populates the catalog when persistence fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const layer = Layer.fresh(
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeFailingWriteKV(cache)],
|
||||
]),
|
||||
)
|
||||
const layer = buildLayer(state, cache, { fetch: true, snapshot: false }, makeFailingWriteCache(cache))
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.has(cacheKey)).toBe(false)
|
||||
expect(cache.values.has(source)).toBe(false)
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const seeded of [false, true]) {
|
||||
it.live(`refresh adopts and publishes the fetched catalog when persistence fails (seeded=${seeded})`, () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
if (seeded) writeCache(cache, fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* models.get()).not.toEqual(fixture2Snapshot)
|
||||
const event = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.andThen(() => models.get()),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* models.refresh(true)
|
||||
expect(yield* Fiber.join(event)).toEqual(fixture2Snapshot)
|
||||
expect(yield* models.get()).toEqual(fixture2Snapshot)
|
||||
yield* models.refresh()
|
||||
expect((yield* Ref.get(state)).calls).toHaveLength(1)
|
||||
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: false }, makeFailingWriteCache(cache))))
|
||||
expect(cache.values.get(source)?.body).toBe(seeded ? JSON.stringify(fixture) : undefined)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("a failed cache read falls back to the bundled snapshot without blocking refresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
expect((yield* models.get()).length).toBeGreaterThan(0)
|
||||
yield* models.refresh(true)
|
||||
expect(yield* models.get()).toEqual(fixtureSnapshot)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
buildLayer(
|
||||
state,
|
||||
cache,
|
||||
{ fetch: false },
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () => Effect.die(new Error("Cache read failed")),
|
||||
write: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect((yield* Ref.get(state)).calls).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh publishes the live catalog while its cache write is still pending", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const writing = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* models.get()).toEqual(fixtureSnapshot)
|
||||
const event = yield* bus
|
||||
.subscribe(ModelsDev.Event.Refreshed)
|
||||
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
|
||||
const refresh = yield* models.refresh(true).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(writing)
|
||||
yield* Fiber.join(event).pipe(Effect.timeout("1 second"))
|
||||
expect(yield* models.get()).toEqual(fixture2Snapshot)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(refresh)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
buildLayer(
|
||||
state,
|
||||
cache,
|
||||
{ fetch: false },
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () => Effect.succeed(cache.values.get(source)),
|
||||
write: () => Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() can use the bundled snapshot while the initial background fetch is pending", () =>
|
||||
Effect.gen(function* () {
|
||||
const reading = yield* Deferred.make<void>()
|
||||
const releaseRead = yield* Deferred.make<void>()
|
||||
const fetching = yield* Deferred.make<void>()
|
||||
const releaseFetch = yield* Deferred.make<void>()
|
||||
const layer = Layer.fresh(
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
|
||||
[
|
||||
ModelsDevCache.node,
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () =>
|
||||
Deferred.succeed(reading, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseRead)),
|
||||
Effect.as(undefined),
|
||||
),
|
||||
write: () => Effect.void,
|
||||
}),
|
||||
],
|
||||
[
|
||||
LayerNodePlatform.httpClient,
|
||||
Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Deferred.succeed(fetching, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseFetch)),
|
||||
Effect.as(HttpClientResponse.fromWeb(request, new Response(JSON.stringify(fixture)))),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
yield* Deferred.await(reading)
|
||||
const get = yield* models.get().pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.succeed(releaseRead, undefined)
|
||||
yield* Deferred.await(fetching)
|
||||
expect((yield* Fiber.join(get).pipe(Effect.timeout("1 second"))).length).toBeGreaterThan(0)
|
||||
yield* Deferred.succeed(releaseFetch, undefined)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("cancelling a reader during initialization does not poison later reads or refreshes", () =>
|
||||
Effect.gen(function* () {
|
||||
const reading = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
const first = yield* models.get().pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(reading)
|
||||
yield* Fiber.interrupt(first)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
expect(yield* models.get()).toEqual(fixtureSnapshot)
|
||||
yield* models.refresh(true)
|
||||
expect(yield* models.get()).toEqual(fixture2Snapshot)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
buildLayer(
|
||||
state,
|
||||
makeCache(),
|
||||
{ fetch: false },
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () =>
|
||||
Deferred.succeed(reading, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as({ body: JSON.stringify(fixture), updatedAt: Date.now() }),
|
||||
),
|
||||
write: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("custom source URLs do not read or overwrite the default source cache", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const result = yield* ModelsDev.Service.use((models) => models.get()).pipe(
|
||||
Effect.provide(buildLayer(state, cache, { url: "https://catalog.example", fetch: true, snapshot: false })),
|
||||
)
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(source)?.body).toBe(JSON.stringify(fixture))
|
||||
expect(cache.values.get("https://catalog.example")?.body).toBe(JSON.stringify(fixture2))
|
||||
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://catalog.example/api.json")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("an explicit file remains authoritative and refresh rereads it without HTTP or cache access", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
const file = path.join(dir.path, "catalog.json")
|
||||
yield* Effect.promise(() => Bun.write(file, JSON.stringify(fixture)))
|
||||
const state = yield* Ref.make(initialState)
|
||||
const cacheCalls: string[] = []
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
expect(yield* models.get()).toEqual(fixtureSnapshot)
|
||||
yield* Effect.promise(() => Bun.write(file, JSON.stringify(fixture2)))
|
||||
yield* models.refresh(true)
|
||||
expect(yield* models.get()).toEqual(fixture2Snapshot)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
buildLayer(
|
||||
state,
|
||||
makeCache(),
|
||||
{ file, fetch: false },
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () =>
|
||||
Effect.sync(() => {
|
||||
cacheCalls.push("read")
|
||||
return undefined
|
||||
}),
|
||||
write: () => Effect.sync(() => void cacheCalls.push("write")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect((yield* Ref.get(state)).calls).toEqual([])
|
||||
expect(cacheCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses the default models URL when the configured URL is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
@@ -348,7 +565,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
|
||||
it.live("get() retains the live catalog instead of rereading persistence", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
@@ -387,7 +604,7 @@ describe("ModelsDev Service", () => {
|
||||
)
|
||||
expect(result.before).toEqual(fixtureSnapshot)
|
||||
expect(result.after).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
expect(cache.values.get(source)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
expect(final.calls[0].url).toContain("/api.json")
|
||||
@@ -395,7 +612,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
|
||||
it.live("refresh(false) skips fetch when the persisted catalog is fresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 1000)
|
||||
@@ -410,7 +627,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) fetches when the KV entry is stale", () =>
|
||||
it.live("refresh(false) fetches when the persisted catalog is stale", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
|
||||
@@ -447,7 +664,7 @@ describe("ModelsDev Service", () => {
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
|
||||
const seeded = structuredClone(cache.values.get(cacheKey))
|
||||
const seeded = structuredClone(cache.values.get(source))
|
||||
// The server serves a byte-identical body, so the refresh still hits
|
||||
// the network but must not rewrite the cache or publish Refreshed.
|
||||
const state = yield* Ref.make(initialState)
|
||||
@@ -474,38 +691,24 @@ describe("ModelsDev Service", () => {
|
||||
)
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
expect(cache.values.get(cacheKey)).toEqual(seeded)
|
||||
expect(cache.values.get(source)).toEqual(seeded)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) republishes once for legacy cache entries without a digest", () =>
|
||||
it.live("concurrent refreshes share the freshness check even when the body is unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
cache.values.set(cacheKey, { updatedAt: Date.now() - 10 * 60 * 1000, body: JSON.stringify(fixture) })
|
||||
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
|
||||
const state = yield* Ref.make(initialState)
|
||||
yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped,
|
||||
Effect.flatMap((fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* svc.refresh(false)
|
||||
return yield* Fiber.join(fiber)
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(refreshed.length).toBe(1)
|
||||
yield* Effect.all([svc.refresh(), svc.refresh(), svc.refresh()], { concurrency: "unbounded" })
|
||||
}),
|
||||
)
|
||||
// The rewritten entry now carries a digest, so later identical bodies stay quiet.
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ digest: bodyDigest(JSON.stringify(fixture)) })
|
||||
expect((yield* Ref.get(state)).calls).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -529,4 +732,25 @@ describe("ModelsDev Service", () => {
|
||||
expect(final.calls.length).toBeGreaterThanOrEqual(1)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const body of ["{", JSON.stringify({ broken: {} })]) {
|
||||
it.live(`refresh preserves the live and persisted catalog when the response is invalid: ${body}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body })
|
||||
yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
const before = yield* models.get()
|
||||
yield* models.refresh(true)
|
||||
expect(yield* models.get()).toBe(before)
|
||||
}),
|
||||
)
|
||||
expect(cache.values.get(source)?.body).toBe(JSON.stringify(fixture))
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -223,7 +223,6 @@ describe("Npm.add", () => {
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
// Several real Git installs and refreshes exceed Bun's default timeout on Windows.
|
||||
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
@@ -263,7 +262,7 @@ describe("Npm.add", () => {
|
||||
return yield* npm.add(mutable, { refresh: true })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
|
||||
}, 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.resolve", () => {
|
||||
|
||||
@@ -282,47 +282,6 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps plugins active when a tool registration is invalid", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const tools = yield* Tool.Service
|
||||
const agents = yield* Agent.Service
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "partial-tools",
|
||||
version: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool.transform((draft) => {
|
||||
const tool = {
|
||||
name: "healthy",
|
||||
description: "Healthy tool",
|
||||
input: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ content: "ok" }),
|
||||
options: { codemode: false },
|
||||
}
|
||||
draft.add({ ...tool, name: "invalid", options: { namespace: "invalid..namespace" } })
|
||||
draft.add(tool)
|
||||
})
|
||||
yield* ctx.agent.transform((draft) =>
|
||||
draft.update("configured", (agent) => {
|
||||
agent.description = "setup continued"
|
||||
}),
|
||||
)
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("partial-tools"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
])
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("setup continued")
|
||||
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
|
||||
yield* plugins.activate([])
|
||||
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("restores the previous plugin when its replacement fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { CommandDefinition } from "@opencode-ai/plugin/effect/command"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { DateTime, Deferred, Effect, PubSub, Stream } from "effect"
|
||||
import { GoalPlugin } from "@opencode-ai/core/plugin/goal"
|
||||
import { it } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
const sessionID = Session.ID.make("ses_goal_test")
|
||||
|
||||
describe("GoalPlugin.Plugin", () => {
|
||||
it.effect("continues a goal until evaluation reports completion", () =>
|
||||
Effect.gen(function* () {
|
||||
const event: SessionEvent.Execution.Succeeded = {
|
||||
id: Event.ID.create(),
|
||||
created: 0,
|
||||
durable: { aggregateID: sessionID, seq: Event.Seq.make(0), version: Event.Version.make(1) },
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID },
|
||||
}
|
||||
const events = yield* PubSub.unbounded<typeof event>()
|
||||
const completed = yield* Deferred.make<void>()
|
||||
const storage = new Map<string, unknown>()
|
||||
const descriptions = new Array<string>()
|
||||
let command: CommandDefinition | undefined
|
||||
|
||||
yield* GoalPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
list: () => Effect.die("unused command.list"),
|
||||
reload: () => Effect.die("unused command.reload"),
|
||||
transform: (callback) => {
|
||||
callback({ add: (definition) => (command = definition) })
|
||||
return Effect.succeed({ dispose: Effect.void })
|
||||
},
|
||||
},
|
||||
event: { subscribe: () => Stream.fromPubSub(events) },
|
||||
storage: {
|
||||
get: (key) => Effect.succeed(storage.get(key) as never),
|
||||
set: (key, value) => Effect.sync(() => storage.set(key, value)),
|
||||
remove: (key) => Effect.sync(() => storage.delete(key)),
|
||||
scan: () => Effect.die("unused storage.scan"),
|
||||
},
|
||||
session: {
|
||||
generate: () => Effect.succeed({ text: "COMPLETE" }),
|
||||
synthetic: (input) =>
|
||||
Effect.gen(function* () {
|
||||
descriptions.push(input.description ?? "")
|
||||
if (input.description === "Goal completed") yield* Deferred.succeed(completed, undefined)
|
||||
return SessionInbox.Synthetic.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(0),
|
||||
type: "synthetic",
|
||||
payload: { text: input.text, description: input.description },
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
if (!command) return yield* Effect.die("Goal command was not registered")
|
||||
|
||||
yield* command.execute({ sessionID, prompt: { text: "Finish the task" }, delivery: "steer" })
|
||||
yield* PubSub.publish(events, event)
|
||||
yield* Deferred.await(completed)
|
||||
|
||||
expect(descriptions).toEqual(["Goal started: Finish the task", "Goal completed"])
|
||||
expect(storage.get(`session/${sessionID}/goal`)).toEqual({ goal: "Finish the task", active: false })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -21,12 +21,7 @@ it.effect("defaults only known Code Mode MCP servers to direct tools", () =>
|
||||
{
|
||||
name: "cloudflare code mode",
|
||||
server: { type: "remote", url: "https://mcp.cloudflare.com/mcp/" },
|
||||
codemode: undefined,
|
||||
},
|
||||
{
|
||||
name: "cloudflare raw tools",
|
||||
server: { type: "remote", url: "https://mcp.cloudflare.com/mcp?codemode=false" },
|
||||
codemode: undefined,
|
||||
codemode: false,
|
||||
},
|
||||
{
|
||||
name: "cloudflare docs",
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -25,9 +23,7 @@ import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "
|
||||
import { eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node])),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node])))
|
||||
|
||||
describe("SessionExecution lifecycle", () => {
|
||||
test("classifies success and typed failure terminals", () => {
|
||||
@@ -64,13 +60,14 @@ describe("SessionExecution lifecycle", () => {
|
||||
const idle = Session.ID.make("ses_recover_idle")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [idle])
|
||||
// Children recover through background Job records, never through the root claim sweep.
|
||||
// An orphaned child is never resumed: the resumed parent re-runs its
|
||||
// tool call and spawns a fresh child instead.
|
||||
yield* seedSessions(database, [child], { time_suspended: Date.now(), parent_id: parent })
|
||||
|
||||
expect(yield* store.listSuspended()).toEqual([parent])
|
||||
|
||||
// The sweep clears orphaned child claims outright; parents keep theirs.
|
||||
yield* store.releaseChildClaims([])
|
||||
yield* store.releaseChildClaims
|
||||
expect(yield* claims(database)).toEqual({ [parent]: true, [child]: false, [idle]: false })
|
||||
}),
|
||||
)
|
||||
@@ -150,66 +147,6 @@ describe("SessionExecution lifecycle", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not resume a user-cancelled background child whose notification was not admitted", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const parent = Session.ID.make("ses_cancelled_background_parent")
|
||||
const child = Session.ID.make("ses_cancelled_background_child")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent })
|
||||
|
||||
const running = yield* Deferred.make<void>()
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const jobs = yield* Job.make.pipe(Scope.provide(scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
() => Deferred.succeed(running, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
undefined,
|
||||
jobs,
|
||||
)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: parent,
|
||||
childSessionID: child,
|
||||
agent: "general",
|
||||
description: "Cancelled inspection",
|
||||
},
|
||||
run: execution.resume(child).pipe(Effect.as("unused")),
|
||||
})
|
||||
yield* jobs.background(child)
|
||||
yield* Deferred.await(running)
|
||||
expect(yield* execution.interrupt(child)).toBeTrue()
|
||||
yield* execution.awaitIdle(child)
|
||||
expect((yield* jobs.wait({ id: child })).info?.status).toBe("cancelled")
|
||||
expect(yield* jobs.pendingBackground).toMatchObject([{ id: child, status: "cancelled" }])
|
||||
expect((yield* claims(database))[child]).toBe(false)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
const restartedScope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(restartedScope, Exit.void))
|
||||
const restartedJobs = yield* Job.make.pipe(Scope.provide(restartedScope))
|
||||
const drained: Session.ID[] = []
|
||||
const restarted = yield* buildExecution(
|
||||
restartedScope,
|
||||
({ sessionID }) => Effect.sync(() => void drained.push(sessionID)),
|
||||
undefined,
|
||||
restartedJobs,
|
||||
)
|
||||
yield* Context.get(restarted, SessionRestart.Service).resumeSuspendedSessions
|
||||
yield* Context.get(restarted, SessionExecution.Service).awaitIdle(parent)
|
||||
expect(drained).toEqual([parent])
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
|
||||
{ payload: { text: expect.stringContaining("Subagent cancelled"), metadata: { state: "cancelled" } } },
|
||||
])
|
||||
expect(yield* restartedJobs.pendingBackground).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
@@ -367,518 +304,6 @@ describe("SessionExecution lifecycle", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionRestart background recovery", () => {
|
||||
it.effect("admits orphaned shell notices without waking and delivers them once on the next run", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const jobs = yield* Job.Service
|
||||
const bus = yield* Bus.Service
|
||||
const parent = Session.ID.make("ses_background_recovery_parent")
|
||||
const child = Session.ID.make("ses_background_recovery_child")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now() })
|
||||
yield* seedBackground(jobs, parent, [
|
||||
{ id: "call-background-shell", shellID: "sh_background_orphan", command: "sleep 60" },
|
||||
])
|
||||
yield* seedBackground(jobs, child, [{ id: "call-child-shell", shellID: "sh_child_orphan", command: "sleep 30" }])
|
||||
|
||||
expect(yield* store.listSuspended()).toEqual([])
|
||||
expect(yield* jobs.pendingBackground).toHaveLength(2)
|
||||
|
||||
const drained: Session.ID[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
({ sessionID }) =>
|
||||
Effect.sync(() => void drained.push(sessionID)).pipe(
|
||||
Effect.andThen(SessionInbox.promote(database.db, bus, sessionID, "steer")),
|
||||
Effect.asVoid,
|
||||
),
|
||||
undefined,
|
||||
restarted,
|
||||
)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* restart.resumeSuspendedSessions
|
||||
|
||||
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
|
||||
{
|
||||
type: "synthetic",
|
||||
payload: {
|
||||
description: "sleep 60",
|
||||
text: expect.stringContaining("server restarted"),
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: "call-background-shell",
|
||||
shellID: "sh_background_orphan",
|
||||
state: "cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(yield* SessionInbox.list(database.db, child)).toMatchObject([
|
||||
{
|
||||
type: "synthetic",
|
||||
payload: {
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: "call-child-shell",
|
||||
shellID: "sh_child_orphan",
|
||||
state: "cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(drained).toEqual([])
|
||||
expect(yield* claims(database)).toEqual({ [parent]: false, [child]: false })
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
|
||||
expect(drained).toEqual([])
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* execution.resume(parent)
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toEqual([])
|
||||
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toHaveLength(1)
|
||||
yield* execution.resume(parent)
|
||||
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves locally running background work", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const jobs = yield* Job.Service
|
||||
const parent = Session.ID.make("ses_background_existing_parent")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedBackground(jobs, parent, [{ id: "call-running-shell", shellID: "sh_running", command: "sleep 60" }])
|
||||
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* buildExecution(scope, () => Effect.void)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* restart.resumeSuspendedSessions
|
||||
|
||||
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toEqual([])
|
||||
expect(yield* jobs.get("call-running-shell")).toMatchObject({ status: "running" })
|
||||
expect(yield* jobs.pendingBackground).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a silent shell failure persisted before its completion notification", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const sessionID = Session.ID.make("ses_background_completed_shell")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
const complete = yield* Deferred.make<string>()
|
||||
yield* jobs.start({
|
||||
id: "call-completed-shell",
|
||||
type: "shell",
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
sessionID,
|
||||
shellID: "sh_completed",
|
||||
command: "exit 7",
|
||||
},
|
||||
run: Deferred.await(complete),
|
||||
})
|
||||
yield* jobs.background("call-completed-shell")
|
||||
yield* Deferred.succeed(complete, "(no output)\n\nCommand exited with code 7.")
|
||||
yield* jobs.wait({ id: "call-completed-shell" })
|
||||
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(scope, () => Effect.void, undefined, restarted)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
|
||||
expect(yield* SessionInbox.list(database.db, sessionID)).toMatchObject([
|
||||
{
|
||||
type: "synthetic",
|
||||
payload: {
|
||||
text: expect.stringContaining("(no output)\n\nCommand exited with code 7."),
|
||||
metadata: { source: "shell", shellID: "sh_completed", state: "completed" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const delivered of [false, true]) {
|
||||
it.effect(`does not duplicate a shell notification already ${delivered ? "delivered" : "admitted"}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const sessions = yield* Session.Service
|
||||
const sessionID = Session.ID.make("ses_shell_notification_retry")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
yield* seedBackground(jobs, sessionID, [
|
||||
{ id: "call-shell-notified", shellID: "sh_notified", command: "echo done" },
|
||||
])
|
||||
const background = (yield* jobs.pendingBackground)[0]
|
||||
if (!background) return yield* Effect.die("background record missing")
|
||||
yield* sessions.synthetic({
|
||||
id: background.notificationID,
|
||||
sessionID,
|
||||
text: "Command already completed",
|
||||
metadata: { source: "shell", shellID: "sh_notified", state: "completed" },
|
||||
resume: false,
|
||||
})
|
||||
if (delivered) yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(scope, () => Effect.void, undefined, restarted)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
|
||||
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
expect(yield* sessions.messages({ sessionID })).toMatchObject([
|
||||
{
|
||||
id: background.notificationID,
|
||||
type: "synthetic",
|
||||
text: "Command already completed",
|
||||
metadata: { state: "completed" },
|
||||
},
|
||||
])
|
||||
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("acknowledges recovery markers when their owning session is deleted", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const sessionID = Session.ID.make("ses_background_deleted")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
yield* seedBackground(jobs, sessionID, [{ id: "call-deleted-shell", shellID: "sh_deleted", command: "sleep 60" }])
|
||||
yield* database.db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
|
||||
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(scope, () => Effect.void, undefined, restarted)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delivers cancellation at the resumed parent's next step", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const bus = yield* Bus.Service
|
||||
const parent = Session.ID.make("ses_background_claimed_parent")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
|
||||
yield* seedBackground(jobs, parent, [{ id: "call-claimed-shell", shellID: "sh_claimed", command: "sleep 60" }])
|
||||
|
||||
const observed = yield* Deferred.make<string[]>()
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
({ sessionID }) =>
|
||||
SessionInbox.promote(database.db, bus, sessionID, "steer").pipe(
|
||||
Effect.andThen(store.context(sessionID)),
|
||||
Effect.orDie,
|
||||
Effect.flatMap((messages) =>
|
||||
Deferred.succeed(
|
||||
observed,
|
||||
messages.filter((message) => message.type === "synthetic").map((message) => message.text),
|
||||
),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
undefined,
|
||||
restarted,
|
||||
)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
expect(yield* Deferred.await(observed)).toEqual([
|
||||
"The server restarted while you were working. Continue from where you left off without repeating completed work.",
|
||||
expect.stringContaining("Command cancelled because the server restarted"),
|
||||
])
|
||||
yield* execution.awaitIdle(parent)
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toEqual([])
|
||||
expect((yield* claims(database))[parent]).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resumes a background subagent and notifies its parent exactly once", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const parent = Session.ID.make("ses_subagent_recovery_parent")
|
||||
const child = Session.ID.make("ses_subagent_recovery_child")
|
||||
const unrelated = Session.ID.make("ses_subagent_unrelated_child")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: 1 })
|
||||
yield* seedSessions(database, [child, unrelated], { parent_id: parent, time_suspended: Date.now() })
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: parent,
|
||||
childSessionID: child,
|
||||
agent: "explore",
|
||||
description: "Inspect recovery",
|
||||
},
|
||||
run: Effect.never,
|
||||
})
|
||||
yield* jobs.background(child)
|
||||
|
||||
const resumed = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const parentResumed = yield* Deferred.make<void>()
|
||||
const parentWoken = yield* Deferred.make<void>()
|
||||
const drained: Session.ID[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
({ sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
drained.push(sessionID)
|
||||
if (sessionID === child) {
|
||||
yield* Deferred.succeed(resumed, undefined)
|
||||
yield* Deferred.await(release)
|
||||
return
|
||||
}
|
||||
yield* Deferred.succeed(
|
||||
drained.filter((id) => id === parent).length === 1 ? parentResumed : parentWoken,
|
||||
undefined,
|
||||
)
|
||||
}),
|
||||
undefined,
|
||||
restarted,
|
||||
)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* restart.resumeSuspendedSessions
|
||||
yield* Deferred.await(resumed)
|
||||
yield* Deferred.await(parentResumed)
|
||||
yield* execution.awaitIdle(parent)
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(drained.toSorted()).toEqual([child, parent].toSorted())
|
||||
expect(yield* claims(database)).toEqual({ [parent]: false, [child]: true, [unrelated]: false })
|
||||
expect(yield* attempts(database, child)).toBe(1)
|
||||
expect(yield* restarted.get(child)).toMatchObject({ status: "running" })
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Deferred.await(parentWoken)
|
||||
expect(drained.filter((id) => id === child)).toHaveLength(1)
|
||||
expect(drained.filter((id) => id === parent)).toHaveLength(2)
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
|
||||
{
|
||||
payload: {
|
||||
description: "Inspect recovery",
|
||||
metadata: { source: "subagent", childID: child, agent: "explore", state: "completed" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delivers a subagent result persisted before restart without rerunning the child", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const parent = Session.ID.make("ses_subagent_completed_parent")
|
||||
const child = Session.ID.make("ses_subagent_completed_child")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent })
|
||||
const complete = yield* Deferred.make<string>()
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: parent,
|
||||
childSessionID: child,
|
||||
agent: "explore",
|
||||
description: "Completed inspection",
|
||||
},
|
||||
run: Deferred.await(complete),
|
||||
})
|
||||
yield* jobs.background(child)
|
||||
yield* Deferred.succeed(complete, "Recovered result")
|
||||
yield* jobs.wait({ id: child })
|
||||
|
||||
const parentWoken = yield* Deferred.make<void>()
|
||||
const drained: Session.ID[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
({ sessionID }) =>
|
||||
Effect.sync(() => void drained.push(sessionID)).pipe(
|
||||
Effect.andThen(Deferred.succeed(parentWoken, undefined)),
|
||||
),
|
||||
undefined,
|
||||
restarted,
|
||||
)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
yield* Deferred.await(parentWoken)
|
||||
|
||||
expect(drained).toEqual([parent])
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
|
||||
{ payload: { text: expect.stringContaining("Recovered result"), metadata: { state: "completed" } } },
|
||||
])
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const resumeAttempts of [1, 2]) {
|
||||
it.effect(`honors a suspended parent's restart budget after ${resumeAttempts} attempts before notifying it`, () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const parent = Session.ID.make("ses_subagent_budget_parent")
|
||||
const children = [
|
||||
Session.ID.make("ses_subagent_budget_child_1"),
|
||||
Session.ID.make("ses_subagent_budget_child_2"),
|
||||
]
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: resumeAttempts })
|
||||
yield* seedSessions(database, children, { parent_id: parent })
|
||||
const complete = yield* Deferred.make<string>()
|
||||
for (const child of children) {
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: parent,
|
||||
childSessionID: child,
|
||||
agent: "explore",
|
||||
description: "Completed inspection",
|
||||
},
|
||||
run: Deferred.await(complete),
|
||||
})
|
||||
yield* jobs.background(child)
|
||||
}
|
||||
yield* Deferred.succeed(complete, "Recovered result")
|
||||
yield* Effect.forEach(children, (id) => jobs.wait({ id }), { discard: true })
|
||||
|
||||
const draining = yield* Deferred.make<number | undefined>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const drained: Session.ID[] = []
|
||||
const continued: Session.ID[] = []
|
||||
yield* bus.project(SessionEvent.Synthetic, (event) =>
|
||||
Effect.sync(() => void continued.push(event.data.sessionID)),
|
||||
)
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Scope.provide(scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
({ sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
drained.push(sessionID)
|
||||
yield* Deferred.succeed(draining, yield* attempts(database, sessionID))
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
{ maxAttempts: 2 },
|
||||
restarted,
|
||||
)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* restart.resumeSuspendedSessions
|
||||
|
||||
if (resumeAttempts < 2) {
|
||||
expect(yield* Deferred.await(draining)).toBe(2)
|
||||
expect(drained).toEqual([parent])
|
||||
expect(continued).toEqual([parent])
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* execution.awaitIdle(parent)
|
||||
}
|
||||
if (resumeAttempts === 2) {
|
||||
expect(drained).toEqual([])
|
||||
expect(continued).toEqual([])
|
||||
}
|
||||
expect((yield* claims(database))[parent]).toBe(false)
|
||||
expect(yield* attempts(database, parent)).toBe(0)
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(2)
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(drained).toHaveLength(resumeAttempts < 2 ? 1 : 0)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("terminalizes a recovered subagent that exhausts its resume budget", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const parent = Session.ID.make("ses_subagent_exhausted_parent")
|
||||
const child = Session.ID.make("ses_subagent_exhausted_child")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now(), resume_attempts: 2 })
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: parent,
|
||||
childSessionID: child,
|
||||
agent: "explore",
|
||||
description: "Exhausted inspection",
|
||||
},
|
||||
run: Effect.never,
|
||||
})
|
||||
yield* jobs.background(child)
|
||||
|
||||
const parentWoken = yield* Deferred.make<void>()
|
||||
const drained: Session.ID[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
({ sessionID }) =>
|
||||
Effect.sync(() => void drained.push(sessionID)).pipe(
|
||||
Effect.andThen(Deferred.succeed(parentWoken, undefined)),
|
||||
),
|
||||
{ maxAttempts: 2 },
|
||||
restarted,
|
||||
)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
yield* Deferred.await(parentWoken)
|
||||
|
||||
expect(drained).toEqual([parent])
|
||||
expect((yield* claims(database))[child]).toBe(false)
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
|
||||
{
|
||||
payload: {
|
||||
text: expect.stringContaining("will not be resumed automatically"),
|
||||
metadata: { source: "subagent", childID: child, state: "error" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionExecution interrupt continuation", () => {
|
||||
it.effect("resumes only steering input after an interrupt with continue", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -1021,27 +446,6 @@ describe("SessionExecution interrupt continuation", () => {
|
||||
)
|
||||
})
|
||||
|
||||
function seedBackground(
|
||||
jobs: Job.Interface,
|
||||
sessionID: Session.ID,
|
||||
background: ReadonlyArray<{ readonly id: string; readonly shellID: string; readonly command: string }>,
|
||||
) {
|
||||
return Effect.forEach(
|
||||
background,
|
||||
(job) =>
|
||||
Effect.gen(function* () {
|
||||
yield* jobs.start({
|
||||
id: job.id,
|
||||
type: "shell",
|
||||
recovery: { kind: "shell", sessionID, shellID: job.shellID, command: job.command },
|
||||
run: Effect.never,
|
||||
})
|
||||
yield* jobs.background(job.id)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
|
||||
/** Plain deliveries seed user prompts; objects seed control items. */
|
||||
function seedInbox(
|
||||
database: Database.Service["Service"],
|
||||
@@ -1127,27 +531,11 @@ function buildExecution(
|
||||
scope: Scope.Closeable,
|
||||
drain: (input: Parameters<SessionRunner.Interface["drain"]>[0]) => Effect.Effect<void, SessionRunner.RunError>,
|
||||
options?: SessionRestart.Options,
|
||||
overrideJobs?: Job.Interface,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const jobs = overrideJobs ?? (yield* Job.Service)
|
||||
const sessions = yield* Session.Service
|
||||
const sessionLayer = Layer.effect(
|
||||
Session.Service,
|
||||
Effect.gen(function* () {
|
||||
const execution = yield* SessionExecution.Service
|
||||
return Session.Service.of({
|
||||
...sessions,
|
||||
synthetic: (input) =>
|
||||
sessions
|
||||
.synthetic({ ...input, resume: false })
|
||||
.pipe(Effect.tap(() => (input.resume === false ? Effect.void : execution.wake(input.sessionID)))),
|
||||
})
|
||||
}),
|
||||
)
|
||||
const runner = Layer.succeed(
|
||||
SessionRunner.Service,
|
||||
SessionRunner.Service.of({
|
||||
@@ -1165,12 +553,10 @@ function buildExecution(
|
||||
)
|
||||
return yield* Layer.buildWithScope(
|
||||
SessionRestart.layer(options).pipe(
|
||||
Layer.provideMerge(sessionLayer),
|
||||
Layer.provideMerge(Layer.fresh(SessionExecution.layer)),
|
||||
Layer.provideMerge(SessionExecution.layer),
|
||||
Layer.provide(Layer.succeed(Database.Service, database)),
|
||||
Layer.provide(Layer.succeed(Bus.Service, bus)),
|
||||
Layer.provide(Layer.succeed(SessionStore.Service, store)),
|
||||
Layer.provide(Layer.succeed(Job.Service, jobs)),
|
||||
Layer.provide(locations),
|
||||
),
|
||||
scope,
|
||||
|
||||
@@ -128,35 +128,6 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a
|
||||
})
|
||||
})
|
||||
|
||||
test("interrupted subagent failures expose their existing child session to the model", async () => {
|
||||
const { published, publisher } = capture("anthropic", { interruptProgress: true })
|
||||
const subagent = LLMEvent.toolCall({
|
||||
id: "call-subagent",
|
||||
name: "subagent",
|
||||
input: { agent: "general", description: "Recover child", prompt: "Continue working" },
|
||||
})
|
||||
await Effect.runPromise(publisher.publish(subagent))
|
||||
await Effect.runPromiseExit(publisher.progress(subagent.id, { sessionID: "ses_existing_child", status: "running" }))
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
|
||||
error: { type: "aborted", message: "Tool execution interrupted (sessionID: ses_existing_child)" },
|
||||
metadata: { sessionID: "ses_existing_child", status: "running" },
|
||||
})
|
||||
})
|
||||
|
||||
test("interrupted non-subagent failures do not expose their progress session IDs", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
await Effect.runPromise(publisher.progress(call.id, { sessionID: "ses_private", status: "running" }))
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
|
||||
error: { type: "aborted", message: "Tool execution interrupted" },
|
||||
metadata: { sessionID: "ses_private", status: "running" },
|
||||
})
|
||||
})
|
||||
|
||||
test("local failure metadata completes the progress snapshot", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { executeTool, toolDefinitions } from "./lib/tool"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { z } from "zod"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -71,49 +71,30 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
|
||||
)
|
||||
|
||||
describe("Tool", () => {
|
||||
it.effect("logs and skips invalid dotted namespaces", () => {
|
||||
const output: unknown[] = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
output.push(entry.message)
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service, { echo: make() }, { namespace: "slack..admin" })
|
||||
|
||||
expect(output).toEqual([
|
||||
[
|
||||
"Skipping invalid tool registration",
|
||||
{ name: "echo", namespace: "slack..admin", error: 'Invalid tool namespace: "slack..admin"' },
|
||||
],
|
||||
])
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(snapshot.codeModeCatalog).toEqual([])
|
||||
}).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
|
||||
it.effect("skips invalid, reserved, and colliding names without dropping healthy tools", () =>
|
||||
it.effect("rejects invalid dotted namespaces", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(
|
||||
service,
|
||||
{
|
||||
before: make(),
|
||||
"": make(),
|
||||
["x".repeat(65)]: make(),
|
||||
"echo.tool": make(),
|
||||
echo_tool: make(),
|
||||
execute: make(),
|
||||
after: make(),
|
||||
},
|
||||
{ codemode: false },
|
||||
const error = yield* transform(service, { echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid and colliding normalized names", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
for (const name of ["", "x".repeat(65)]) {
|
||||
const invalid = yield* transform(service, { [name]: make() }, { codemode: false }).pipe(Effect.flip)
|
||||
expect(invalid.message).toBe(`Invalid tool name: ${name}`)
|
||||
}
|
||||
|
||||
const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false }).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["after", "before", "execute"])
|
||||
expect((yield* snapshot.execute(call("before"))).output).toEqual({ text: "before" })
|
||||
expect((yield* snapshot.execute(call("after"))).output).toEqual({ text: "after" })
|
||||
expect((yield* snapshot.execute(call("echo_tool")).pipe(Effect.flip)).message).toBe("Unknown tool: echo_tool")
|
||||
expect(snapshot.codeModeCatalog).toEqual([])
|
||||
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -178,72 +159,40 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps healthy tools when another namespace is invalid", () =>
|
||||
it.effect("validates a registration batch before installing any tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* service.transform((draft) => {
|
||||
draft.add({ ...make(), name: "first", options: { codemode: false } })
|
||||
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
|
||||
draft.add({ ...make(), name: "second", options: { namespace: "invalid__namespace" } })
|
||||
})
|
||||
const error = yield* service
|
||||
.transform((draft) => {
|
||||
draft.add({ ...make(), name: "first", options: { codemode: false } })
|
||||
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["first", "execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["invalid__namespace.second"])
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("logs invalid tool definitions without dropping healthy tools", () => {
|
||||
const output: unknown[] = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
output.push(entry.message)
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* service.transform((draft) => {
|
||||
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
|
||||
draft.add({
|
||||
name: "phone_type",
|
||||
input: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ content: "ok" }),
|
||||
options: { codemode: false },
|
||||
} as unknown as Info)
|
||||
draft.add({ ...make(), name: "codemode" })
|
||||
})
|
||||
|
||||
expect(output).toEqual([
|
||||
[
|
||||
"Skipping invalid tool registration",
|
||||
{
|
||||
name: "phone_type",
|
||||
namespace: undefined,
|
||||
error: expect.stringContaining('Expected string\n at ["description"]'),
|
||||
},
|
||||
],
|
||||
])
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
|
||||
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
|
||||
}).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
|
||||
it.effect("skipped registrations leave existing tools and scoped cleanup intact", () =>
|
||||
it.effect("rejects invalid tool definitions before installing any tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service, { echo: constant("original") }, { codemode: false })
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => {
|
||||
draft.add({ ...constant("invalid"), name: "echo", description: undefined } as unknown as Info)
|
||||
draft.add({ ...make(), name: "temporary", options: { codemode: false } })
|
||||
})
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["echo", "temporary", "execute"])
|
||||
expect((yield* snapshot.execute(call("echo"))).output).toEqual({ text: "original" })
|
||||
}),
|
||||
)
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
|
||||
const error = yield* service
|
||||
.transform((draft) => {
|
||||
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
|
||||
draft.add({
|
||||
name: "phone_type",
|
||||
input: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ content: "ok" }),
|
||||
options: { codemode: false },
|
||||
} as unknown as Info)
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect(error.name).toBe("phone_type")
|
||||
expect(error.message).toContain('Expected string\n at ["description"]')
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { asc, desc, eq, sql } from "drizzle-orm"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
@@ -1444,49 +1444,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delivers controls without preflighting unavailable initial instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const runner = yield* SessionRunner.Service
|
||||
systemUnavailable = true
|
||||
let reads = 0
|
||||
systemLoadHook = Effect.sync(() => {
|
||||
reads++
|
||||
})
|
||||
const compaction = yield* SessionInbox.admitCompaction(database.db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
delivery: "queue",
|
||||
})
|
||||
yield* SessionInbox.admit(database.db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
|
||||
expect(yield* runner.drain({ sessionID, force: false })).toEqual(SessionRunner.DrainResult.Moved({}))
|
||||
|
||||
expect(reads).toBe(0)
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
|
||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delivers a queued move atomically at the idle boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -1597,56 +1554,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs a queued control on Location entry before a carried continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const runner = yield* SessionRunner.Service
|
||||
yield* admit(session, "Echo before moving")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.tool("call-entry", "echo", { text: "moving" }),
|
||||
TestLLM.text("Entry summary", "entry-summary"),
|
||||
TestLLM.text("Continued", "entry-continuation"),
|
||||
)
|
||||
const stream = yield* TestLLM.gate
|
||||
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
|
||||
yield* stream.started
|
||||
const compaction = yield* SessionInbox.admitCompaction(database.db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
delivery: "queue",
|
||||
})
|
||||
yield* SessionInbox.admit(database.db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
yield* stream.release
|
||||
const moved = yield* Fiber.join(run)
|
||||
|
||||
expect(moved).toEqual(SessionRunner.DrainResult.Moved({ continuation: { step: 2 } }))
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* SessionInbox.find(database.db, compaction.id)).toMatchObject({ id: compaction.id })
|
||||
if (moved._tag !== "Moved") throw new Error("Expected a Location handoff")
|
||||
|
||||
// Location entry considers queued controls even when model work carries across the move.
|
||||
yield* runner.drain({ sessionID, force: false, continuation: moved.continuation })
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
|
||||
expect(userTexts(requests[2])[0]).toContain("<summary>\nEntry summary\n</summary>")
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("seeds a fork with the parent's newest instruction values", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -2655,74 +2562,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("refreshes preparation after overflow compaction without promoting new input", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
const bus = yield* Bus.Service
|
||||
let reads = 0
|
||||
let resolutions = 0
|
||||
systemLoadHook = Effect.sync(() => {
|
||||
reads++
|
||||
})
|
||||
modelResolveHook = Effect.sync(() => {
|
||||
resolutions++
|
||||
})
|
||||
yield* admit(session, "Continue")
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
TestLLM.text("Overflow summary", "overflow-summary"),
|
||||
TestLLM.text("Recovered", "overflow-recovered"),
|
||||
TestLLM.stop(),
|
||||
TestLLM.stop(),
|
||||
)
|
||||
const first = yield* TestLLM.gate
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* first.started
|
||||
expect(reads).toBe(1)
|
||||
expect(resolutions).toBe(1)
|
||||
expect(requests[0]?.model).toBe(recoveryModel)
|
||||
|
||||
const summary = yield* TestLLM.gate
|
||||
yield* first.release
|
||||
yield* summary.started
|
||||
systemBaseline = "Changed during compaction"
|
||||
yield* bus.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
|
||||
})
|
||||
const queued = yield* session.prompt({
|
||||
sessionID,
|
||||
text: "Queued during compaction",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
const steered = yield* admit(session, "Steered during compaction")
|
||||
const retry = yield* TestLLM.gate
|
||||
yield* summary.release
|
||||
yield* retry.started
|
||||
|
||||
expect(reads).toBe(2)
|
||||
expect(resolutions).toBe(2)
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(requests[2]?.model).toBe(replacementModel)
|
||||
expect(requests[2]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(systemTexts(requests[2])).toContain("Changed during compaction")
|
||||
expect(userTexts(requests[2])[0]).toContain("<summary>\nOverflow summary\n</summary>")
|
||||
expect(userTexts(requests[2]).join("\n")).not.toContain("Queued during compaction")
|
||||
expect(userTexts(requests[2]).join("\n")).not.toContain("Steered during compaction")
|
||||
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([queued.id, steered.id])
|
||||
|
||||
yield* retry.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(5)
|
||||
expect(userTexts(requests[3])).toContain("Steered during compaction")
|
||||
expect(userTexts(requests[3])).not.toContain("Queued during compaction")
|
||||
expect(userTexts(requests[4])).toContain("Queued during compaction")
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not recover provider context overflow when automatic compaction is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
@@ -3481,71 +3320,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps queued input parked when a steer is cancelled during preparation", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const runner = yield* SessionRunner.Service
|
||||
yield* admit(session, "A")
|
||||
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop(), TestLLM.stop())
|
||||
const stream = yield* TestLLM.gate
|
||||
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
|
||||
yield* stream.started
|
||||
|
||||
yield* session.prompt({ sessionID, text: "B", delivery: "queue", resume: false })
|
||||
const steer = yield* admit(session, "S")
|
||||
systemLoadHook = Effect.gen(function* () {
|
||||
systemLoadHook = Effect.void
|
||||
yield* session.cancelInbox({ sessionID, inboxID: steer.id }).pipe(Effect.orDie)
|
||||
})
|
||||
yield* stream.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests.map(userTexts)).toEqual([["A"], ["A"], ["A", "B"]])
|
||||
expect((yield* session.messages({ sessionID })).some((message) => message.id === steer.id)).toBe(false)
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("dispatches a queued move when a steer is cancelled during preparation", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const runner = yield* SessionRunner.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/moved") })
|
||||
yield* admit(session, "A")
|
||||
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
|
||||
const stream = yield* TestLLM.gate
|
||||
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
|
||||
yield* stream.started
|
||||
|
||||
yield* SessionInbox.admit(database.db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: { location, projectID: Project.ID.global },
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
const steer = yield* admit(session, "S")
|
||||
systemLoadHook = Effect.gen(function* () {
|
||||
systemLoadHook = Effect.void
|
||||
yield* session.cancelInbox({ sessionID, inboxID: steer.id }).pipe(Effect.orDie)
|
||||
})
|
||||
yield* stream.release
|
||||
|
||||
expect({ result: yield* Fiber.join(run), location: (yield* session.get(sessionID)).location }).toEqual({
|
||||
result: SessionRunner.DrainResult.Moved({}),
|
||||
location,
|
||||
})
|
||||
expect(requests.map(userTexts)).toEqual([["A"], ["A"]])
|
||||
expect(closedTransports).toEqual([sessionID])
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain(Bus.versionedType(SessionEvent.Moved.type, 1))
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves durable queued input for a later wake after interruption", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -3866,81 +3640,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a stale subagent child session in its model-visible failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
const database = yield* Database.Service
|
||||
yield* admit(session, "Recover interrupted subagent")
|
||||
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: { id: ID.make("fake-model"), providerID: Provider.ID.make("fake") },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-interrupted-subagent",
|
||||
name: "subagent",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-interrupted-subagent",
|
||||
text: '{"agent":"general"}',
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-interrupted-subagent",
|
||||
input: { agent: "general" },
|
||||
executed: false,
|
||||
})
|
||||
yield* database.db
|
||||
.update(SessionMessageTable)
|
||||
.set({
|
||||
data: sql`json_set(
|
||||
${SessionMessageTable.data},
|
||||
'$.content[0].state.metadata',
|
||||
json('{"sessionID":"ses_existing_child","status":"running","internal":"private"}')
|
||||
)`,
|
||||
})
|
||||
.where(eq(SessionMessageTable.id, assistantMessageID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
requests.length = 0
|
||||
yield* TestLLM.push([])
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Recover interrupted subagent" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-interrupted-subagent",
|
||||
state: {
|
||||
status: "error",
|
||||
error: {
|
||||
type: "aborted",
|
||||
message: "Tool execution interrupted: subagent (sessionID: ses_existing_child)",
|
||||
},
|
||||
metadata: { sessionID: "ses_existing_child", status: "running", internal: "private" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
const modelResult = JSON.stringify(requests[0]?.messages.at(-1))
|
||||
expect(modelResult).toContain("ses_existing_child")
|
||||
expect(modelResult).not.toContain("private")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("durably fails hosted tools left running by a prior process before continuing inline", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -790,48 +790,6 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("persists a silent command that finishes before backgrounding", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const shell = yield* Shell.Service
|
||||
const persisted = yield* Deferred.make<readonly Job.Background[]>()
|
||||
yield* bus.project(SessionEvent.InboxEnqueued, (event) =>
|
||||
event.data.sessionID === sessionID && event.data.item.type === "synthetic"
|
||||
? jobs.pendingBackground.pipe(
|
||||
Effect.flatMap((background) => Deferred.succeed(persisted, background)),
|
||||
Effect.asVoid,
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* executeTool(registry, {
|
||||
...call({ command: "exit 7", background: true }, "call-background-silent-nonzero"),
|
||||
// The command can finish while its initial progress update is being published.
|
||||
progress: (update) =>
|
||||
typeof update.shellID === "string"
|
||||
? shell.wait(ShellSchema.ID.make(update.shellID)).pipe(Effect.orDie, Effect.asVoid)
|
||||
: Effect.void,
|
||||
})
|
||||
|
||||
expect(yield* Deferred.await(persisted)).toMatchObject([
|
||||
{
|
||||
id: "call-background-silent-nonzero",
|
||||
status: "completed",
|
||||
output: "(no output)\n\nCommand exited with code 7.",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"updates and clears a running shell timeout",
|
||||
() =>
|
||||
|
||||
@@ -114,24 +114,18 @@ describe("WebFetchTool helpers", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("before after")
|
||||
})
|
||||
|
||||
test("is deterministic and bounded for malformed input across parser chunks", () => {
|
||||
const html = `<main><p>${"visible & text ".repeat(4_096)}</main></p></unknown>`
|
||||
test("is deterministic and bounded for malformed maximum-size input", () => {
|
||||
const html = `<main><p>${"visible & text ".repeat(250_000)}</main></p></unknown>`
|
||||
const first = WebFetchTool.convertHTMLToMarkdown(html)
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(first)
|
||||
expect(first.startsWith("visible & text visible & text")).toBe(true)
|
||||
expect(first.length).toBeLessThanOrEqual(html.length)
|
||||
})
|
||||
|
||||
test("defaults to the production byte budget with room for closing syntax", () => {
|
||||
const output = WebFetchTool.convertHTMLToMarkdown("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES))
|
||||
expect(WebFetchTool.MAX_RESPONSE_BYTES).toBe(5 * 1024 * 1024)
|
||||
expect(output).toHaveLength(WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024)
|
||||
})
|
||||
|
||||
test("bounds deeply nested list output and fragmented code fences", () => {
|
||||
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
|
||||
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
|
||||
const code = `<pre>${"` x ".repeat(4_096)}</pre>`
|
||||
const code = `<pre>${"` x ".repeat(250_000)}</pre>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
|
||||
expect(() => WebFetchTool.convertHTMLToMarkdown(code)).not.toThrow()
|
||||
@@ -256,35 +250,46 @@ describe("WebFetchTool helpers", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`| a\\|b next | \`x\\|y\` |\n| --- | --- |`)
|
||||
})
|
||||
|
||||
test("preserves Unicode in inline constructs", () => {
|
||||
const payload = "😀".repeat(16)
|
||||
test("keeps each near-boundary inline construct closed and UTF-8-safe", () => {
|
||||
const payload = "😀".repeat(WebFetchTool.MAX_RESPONSE_BYTES / 4)
|
||||
const cases = [
|
||||
[`<strong>${payload}</strong>`, `**${payload}**`],
|
||||
[`<a href="/docs">${payload}</a>`, `[${payload}](/docs)`],
|
||||
[`<img src="image.png" alt="${payload}">`, ``],
|
||||
[`<code>${payload}</code>`, `\`${payload}\``],
|
||||
[`<strong>${payload}</strong>`, /^\*\*[\s\S]*\*\*$/],
|
||||
[`<a href="/docs">${payload}</a>`, /^\[[\s\S]*\]\(\/docs\)$/],
|
||||
[`<img src="image.png" alt="${payload}">`, /^!\[[\s\S]*\]\(image\.png\)$/],
|
||||
[`<code>${payload}</code>`, /^`[\s\S]*`$/],
|
||||
] as const
|
||||
for (const [html, expected] of cases) {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(expected)
|
||||
for (const [html, pattern] of cases) {
|
||||
const output = WebFetchTool.convertHTMLToMarkdown(html)
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
expect(output).not.toContain("�")
|
||||
expect(output).toMatch(pattern)
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves block content and following lists", () => {
|
||||
const payload = "x".repeat(256)
|
||||
test("keeps near-boundary block constructs syntactically complete", () => {
|
||||
const payload = "x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
const table = WebFetchTool.convertHTMLToMarkdown(
|
||||
`<table><tr><th>Name</th></tr><tr><td>${payload}</td></tr></table>`,
|
||||
)
|
||||
const list = WebFetchTool.convertHTMLToMarkdown(`<ul><li>${payload}</li></ul><ul><li>next</li></ul>`)
|
||||
const list = WebFetchTool.convertHTMLToMarkdown(`<ul><li>${payload}</li></ul><ul><li>nested</li></ul>`)
|
||||
const code = WebFetchTool.convertHTMLToMarkdown(`<pre>${payload}</pre>`)
|
||||
expect(table).toBe(`| Name |\n| --- |\n| ${payload} |`)
|
||||
expect(list).toBe(`- ${payload}\n\n- next`)
|
||||
expect(code).toBe(`\`\`\`\n${payload}\n\`\`\``)
|
||||
for (const output of [table, list, code]) {
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
expect(output).not.toContain("�")
|
||||
}
|
||||
expect(table).toMatch(/^\| Name \|\n\| --- \|\n\| [\s\S]* \|$/)
|
||||
expect(list).toMatch(/^- [\s\S]*$/)
|
||||
expect(list.includes("nested")).toBe(false)
|
||||
expect(code.match(/^(`{3,}|~{3,})$/gm)).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("keeps quoted code with long delimiter runs inside a safe closed fence", () => {
|
||||
const payload = `${"`".repeat(32)}${"~".repeat(32)}${"x".repeat(64)}`
|
||||
const output = WebFetchTool.convertHTMLToMarkdown(`<blockquote><pre>${payload}</pre></blockquote>`)
|
||||
expect(output).toBe(`> ${"`".repeat(33)}\n> ${payload}\n> ${"`".repeat(33)}`)
|
||||
test("keeps quoted code within budget with a safe closed fence", () => {
|
||||
const html = `<blockquote><pre>${"`".repeat(32)}${"~".repeat(32)}${"x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</pre></blockquote>`
|
||||
const output = WebFetchTool.convertHTMLToMarkdown(html)
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
const lines = output.split("\n")
|
||||
expect(lines[0]).toMatch(/^> (`{33}|~{33})$/)
|
||||
expect(lines.at(-1)).toBe(lines[0])
|
||||
})
|
||||
|
||||
test("separates reconstructed tables from adjacent inline and quoted content", () => {
|
||||
@@ -294,9 +299,13 @@ describe("WebFetchTool helpers", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps multiline quoted code closed before following prose", () => {
|
||||
const html = `<blockquote><pre>${"x\n".repeat(16)}</pre></blockquote><p>tail</p>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`> \`\`\`\n${"> x\n".repeat(16)}> \`\`\`\n\ntail`)
|
||||
test("keeps multiline quoted code closed at the content budget", () => {
|
||||
const html = `<blockquote><pre>${"x\n".repeat(WebFetchTool.MAX_RESPONSE_BYTES / 2)}</pre></blockquote><p>tail</p>`
|
||||
const output = WebFetchTool.convertHTMLToMarkdown(html)
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
expect((output.match(/(`{3}|~{3})/g) ?? []).length).toBe(2)
|
||||
expect(output.includes("\uFFFD")).toBe(false)
|
||||
expect(output.endsWith("tail")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps active content suppressed when depth fallback begins", () => {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/latex",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./markdown": "./src/markdown.ts",
|
||||
"./plugin": "./src/plugin.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { layoutMath } from "./layout"
|
||||
import { renderLatexToString } from "./render"
|
||||
|
||||
const text = (value: string) => ({ type: "text" as const, value })
|
||||
|
||||
describe("structured math layout", () => {
|
||||
test.each([
|
||||
String.raw`\sqrt{x}`,
|
||||
String.raw`\begin{pmatrix}a&b\\c&d\end{pmatrix}`,
|
||||
String.raw`\underbrace{abcd}`,
|
||||
String.raw`\overbrace{abcd}`,
|
||||
String.raw`\sum`,
|
||||
])("empty scripts do not change geometry: %s", (source) => {
|
||||
for (const scripts of ["^{}", "_{}", "^{}_{}"]) {
|
||||
expect(renderLatexToString(source + scripts)).toBe(renderLatexToString(source))
|
||||
}
|
||||
})
|
||||
|
||||
test("centers annotations over even-width brace junctions", () => {
|
||||
expect(renderLatexToString(String.raw`\overbrace{abcd}^{n}`)).toBe([" n", "╭┴─╮", "abcd"].join("\n"))
|
||||
expect(renderLatexToString(String.raw`\underbrace{abcd}_{n}`)).toBe(["abcd", "╰┬─╯", " n"].join("\n"))
|
||||
})
|
||||
|
||||
test("raises powers above tall matrix delimiters", () => {
|
||||
expect(renderLatexToString(String.raw`\begin{pmatrix}a&b\\c&d\end{pmatrix}^2`)).toBe(
|
||||
[" 2", "⎛a b⎞", "⎜ ⎟", "⎝c d⎠"].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps piecewise values left-aligned", () => {
|
||||
expect(renderLatexToString(String.raw`\begin{cases}x & x>0\\x^2+1 & x\le0\end{cases}`)).toBe(
|
||||
["⎧x x > 0", "⎨", "⎩x² + 1 x ≤ 0"].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("honors array column alignment and continuous separators", () => {
|
||||
expect(
|
||||
layoutMath({
|
||||
type: "matrix",
|
||||
environment: "array",
|
||||
columns: "l|r",
|
||||
rows: [
|
||||
[text("a"), text("wide")],
|
||||
[text("long"), text("b")],
|
||||
],
|
||||
}).toString(),
|
||||
).toBe(["a │ wide", " │", "long │ b"].join("\n"))
|
||||
})
|
||||
|
||||
test("preserves edge rules and double array separators", () => {
|
||||
expect(
|
||||
layoutMath({
|
||||
type: "matrix",
|
||||
environment: "array",
|
||||
columns: "|l||r|",
|
||||
rows: [
|
||||
[text("a"), text("b")],
|
||||
[text("long"), text("c")],
|
||||
],
|
||||
}).toString(),
|
||||
).toBe(["│ a ││ b │", "│ ││ │", "│ long ││ c │"].join("\n"))
|
||||
})
|
||||
|
||||
test.each(["left", "right"] as const)("aligns continued-fraction numerators to the %s", (numeratorAlign) => {
|
||||
const layout = layoutMath({
|
||||
type: "fraction",
|
||||
numerator: text("1"),
|
||||
denominator: text("12345"),
|
||||
bar: true,
|
||||
numeratorAlign,
|
||||
})
|
||||
expect(layout.toString()).toBe([numeratorAlign === "left" ? " 1" : " 1", "───────", " 12345"].join("\n"))
|
||||
})
|
||||
|
||||
test.each(["over", "under"] as const)("stretches %s braces and places annotations outside them", (position) => {
|
||||
const layout = layoutMath({
|
||||
type: "scripts",
|
||||
base: { type: "brace", body: text("a + b + c"), position },
|
||||
...(position === "over" ? { superscript: text("n") } : { subscript: text("n") }),
|
||||
})
|
||||
expect(layout.toString()).toBe(
|
||||
(position === "over" ? [" n", "╭───┴───╮", "a + b + c"] : ["a + b + c", "╰───┬───╯", " n"]).join("\n"),
|
||||
)
|
||||
expect(layout.baseline).toBe(position === "over" ? 2 : 0)
|
||||
})
|
||||
})
|
||||
@@ -1,673 +0,0 @@
|
||||
import type { MathCell, MathLayout, MathNode, MathStyle, MathVariant, RenderLatexOptions, SymbolRole } from "./types"
|
||||
|
||||
interface Box {
|
||||
width: number
|
||||
height: number
|
||||
baseline: number
|
||||
cells: Array<Array<MathCell | undefined>>
|
||||
}
|
||||
|
||||
interface LayoutContext {
|
||||
displayMode: boolean
|
||||
compactScripts: boolean
|
||||
style?: MathStyle
|
||||
variant?: MathVariant
|
||||
}
|
||||
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
const superscripts: Readonly<Record<string, string>> = {
|
||||
"0": "⁰",
|
||||
"1": "¹",
|
||||
"2": "²",
|
||||
"3": "³",
|
||||
"4": "⁴",
|
||||
"5": "⁵",
|
||||
"6": "⁶",
|
||||
"7": "⁷",
|
||||
"8": "⁸",
|
||||
"9": "⁹",
|
||||
"+": "⁺",
|
||||
"-": "⁻",
|
||||
"=": "⁼",
|
||||
"(": "⁽",
|
||||
")": "⁾",
|
||||
n: "ⁿ",
|
||||
i: "ⁱ",
|
||||
}
|
||||
|
||||
const subscripts: Readonly<Record<string, string>> = {
|
||||
"0": "₀",
|
||||
"1": "₁",
|
||||
"2": "₂",
|
||||
"3": "₃",
|
||||
"4": "₄",
|
||||
"5": "₅",
|
||||
"6": "₆",
|
||||
"7": "₇",
|
||||
"8": "₈",
|
||||
"9": "₉",
|
||||
"+": "₊",
|
||||
"-": "₋",
|
||||
"=": "₌",
|
||||
"(": "₍",
|
||||
")": "₎",
|
||||
a: "ₐ",
|
||||
e: "ₑ",
|
||||
h: "ₕ",
|
||||
i: "ᵢ",
|
||||
j: "ⱼ",
|
||||
k: "ₖ",
|
||||
l: "ₗ",
|
||||
m: "ₘ",
|
||||
n: "ₙ",
|
||||
o: "ₒ",
|
||||
p: "ₚ",
|
||||
r: "ᵣ",
|
||||
s: "ₛ",
|
||||
t: "ₜ",
|
||||
u: "ᵤ",
|
||||
v: "ᵥ",
|
||||
x: "ₓ",
|
||||
}
|
||||
|
||||
export function layoutMath(node: MathNode, options: RenderLatexOptions = {}): MathLayout {
|
||||
const context: LayoutContext = {
|
||||
displayMode: options.displayMode ?? true,
|
||||
compactScripts: options.compactScripts ?? true,
|
||||
...(options.color ? { style: { color: options.color } } : {}),
|
||||
}
|
||||
return asPublicLayout(layoutNode(node, context))
|
||||
}
|
||||
|
||||
function layoutNode(node: MathNode, context: LayoutContext): Box {
|
||||
switch (node.type) {
|
||||
case "row":
|
||||
return layoutRow(node.body, context)
|
||||
case "symbol":
|
||||
case "text":
|
||||
case "operator":
|
||||
return textBox(applyVariant(node.value, context.variant), context.style)
|
||||
case "space":
|
||||
return blank(node.width, 1, 0)
|
||||
case "fraction":
|
||||
return layoutFraction(node, context)
|
||||
case "root":
|
||||
return layoutRoot(node.body, node.index, context)
|
||||
case "scripts":
|
||||
return layoutScripts(node, context)
|
||||
case "delimited":
|
||||
return layoutDelimited(node.left, node.body, node.right, context)
|
||||
case "matrix":
|
||||
return layoutMatrix(node, context)
|
||||
case "brace":
|
||||
return layoutBrace(node, context)
|
||||
case "accent":
|
||||
return layoutAccent(node.accent, node.body, context)
|
||||
case "variant":
|
||||
return layoutNode(node.body, withVariant(context, node.variant))
|
||||
case "overunder":
|
||||
return layoutOverUnder(node.base, node.over, node.under, context)
|
||||
case "color":
|
||||
return layoutNode(node.body, { ...context, style: { ...context.style, color: node.color } })
|
||||
}
|
||||
throw new Error("Unsupported math node")
|
||||
}
|
||||
|
||||
function layoutRow(nodes: MathNode[], context: LayoutContext): Box {
|
||||
if (nodes.length === 0) return blank(0, 1, 0)
|
||||
|
||||
const boxes: Box[] = []
|
||||
let previousRole: SymbolRole | undefined
|
||||
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const node = nodes[index]
|
||||
const rawRole = nodeRole(node)
|
||||
const role = normalizeBinaryRole(rawRole, previousRole, nextSignificantRole(nodes, index + 1))
|
||||
if (needsMathSpace(previousRole, role, boxes.length)) boxes.push(blank(1, 1, 0))
|
||||
boxes.push(layoutNode(node, context))
|
||||
if (node.type !== "space") previousRole = role ?? "ordinary"
|
||||
}
|
||||
|
||||
return hpack(boxes)
|
||||
}
|
||||
|
||||
function layoutFraction(node: Extract<MathNode, { type: "fraction" }>, context: LayoutContext): Box {
|
||||
const numerator = layoutNode(node.numerator, context)
|
||||
const denominator = layoutNode(node.denominator, context)
|
||||
const width = Math.max(numerator.width, denominator.width) + 2
|
||||
// Barless fractions (binomials) still reserve an axis row so surrounding
|
||||
// atoms and their stretching parentheses align between the two entries.
|
||||
const gap = 1
|
||||
const height = numerator.height + denominator.height + gap
|
||||
// TeX places a fraction's math axis on its rule (or the equivalent empty
|
||||
// axis row for a binomial). Align neighbors there, not on the denominator.
|
||||
const baseline = numerator.height
|
||||
const result = blank(width, height, baseline)
|
||||
|
||||
const numeratorX =
|
||||
node.numeratorAlign === "left"
|
||||
? 1
|
||||
: node.numeratorAlign === "right"
|
||||
? width - numerator.width - 1
|
||||
: Math.floor((width - numerator.width) / 2)
|
||||
overlay(result, numerator, numeratorX, 0)
|
||||
if (node.bar) drawHorizontal(result, numerator.height, 0, width, "─", context.style)
|
||||
overlay(result, denominator, Math.floor((width - denominator.width) / 2), numerator.height + gap)
|
||||
return result
|
||||
}
|
||||
|
||||
function layoutRoot(bodyNode: MathNode, indexNode: MathNode | undefined, context: LayoutContext): Box {
|
||||
const body = layoutNode(bodyNode, context)
|
||||
const index = indexNode ? layoutNode(indexNode, context) : undefined
|
||||
const indexWidth = index ? Math.max(0, index.width - 1) : 0
|
||||
// The index ends beside the overbar, never inside the hook or radicand.
|
||||
const top = Math.max(0, (index?.height ?? 1) - 1)
|
||||
const bodyX = indexWidth + 2
|
||||
const width = bodyX + body.width
|
||||
const height = top + body.height + 1
|
||||
const baseline = top + body.baseline + 1
|
||||
const result = blank(width, height, baseline)
|
||||
|
||||
setCell(result, bodyX - 1, top, "╭", context.style)
|
||||
drawHorizontal(result, top, bodyX, body.width, "─", context.style)
|
||||
for (let y = top + 1; y < height - 1; y++) setCell(result, bodyX - 1, y, "│", context.style)
|
||||
setCell(result, bodyX - 2, height - 1, "╰", context.style)
|
||||
setCell(result, bodyX - 1, height - 1, "╯", context.style)
|
||||
overlay(result, body, bodyX, top + 1)
|
||||
if (index) overlay(result, index, 0, 0)
|
||||
return result
|
||||
}
|
||||
|
||||
function layoutScripts(node: Extract<MathNode, { type: "scripts" }>, context: LayoutContext): Box {
|
||||
const superscriptNode = node.superscript && simpleNodeText(node.superscript) !== "" ? node.superscript : undefined
|
||||
const subscriptNode = node.subscript && simpleNodeText(node.subscript) !== "" ? node.subscript : undefined
|
||||
if (node.base.type === "brace" || (node.base.type === "operator" && node.base.limits && context.displayMode)) {
|
||||
return layoutOverUnder(node.base, superscriptNode, subscriptNode, context)
|
||||
}
|
||||
|
||||
const base = layoutNode(node.base, context)
|
||||
if (context.compactScripts && base.height === 1) {
|
||||
const superscript = mapScript(superscriptNode ? simpleNodeText(superscriptNode) : "", superscripts)
|
||||
const subscript = mapScript(subscriptNode ? simpleNodeText(subscriptNode) : "", subscripts)
|
||||
if (superscript !== undefined && subscript !== undefined) {
|
||||
return hpack([base, textBox(superscript + subscript, context.style)])
|
||||
}
|
||||
}
|
||||
|
||||
const superscript = superscriptNode ? layoutNode(superscriptNode, context) : undefined
|
||||
const subscript = subscriptNode ? layoutNode(subscriptNode, context) : undefined
|
||||
const scriptWidth = Math.max(superscript?.width ?? 0, subscript?.width ?? 0)
|
||||
const topHeight = superscript?.height ?? 0
|
||||
const bottomHeight = subscript?.height ?? 0
|
||||
const width = base.width + scriptWidth
|
||||
const height = topHeight + base.height + bottomHeight
|
||||
const baseline = topHeight + base.baseline
|
||||
const result = blank(width, height, baseline)
|
||||
|
||||
overlay(result, base, 0, topHeight)
|
||||
if (superscript) overlay(result, superscript, base.width, 0)
|
||||
if (subscript) overlay(result, subscript, base.width, topHeight + base.height)
|
||||
return result
|
||||
}
|
||||
|
||||
function layoutOverUnder(
|
||||
baseNode: MathNode,
|
||||
overNode: MathNode | undefined,
|
||||
underNode: MathNode | undefined,
|
||||
context: LayoutContext,
|
||||
): Box {
|
||||
const base = layoutNode(baseNode, context)
|
||||
const over = overNode ? layoutNode(overNode, context) : undefined
|
||||
const under = underNode ? layoutNode(underNode, context) : undefined
|
||||
const width = Math.max(base.width, over?.width ?? 0, under?.width ?? 0)
|
||||
const overHeight = over?.height ?? 0
|
||||
const height = overHeight + base.height + (under?.height ?? 0)
|
||||
const baseline = overHeight + base.baseline
|
||||
const result = blank(width, height, baseline)
|
||||
|
||||
if (over) overlay(result, over, Math.floor((width - over.width) / 2), 0)
|
||||
overlay(result, base, Math.floor((width - base.width) / 2), overHeight)
|
||||
if (under) overlay(result, under, Math.floor((width - under.width) / 2), overHeight + base.height)
|
||||
return result
|
||||
}
|
||||
|
||||
function layoutDelimited(left: string, bodyNode: MathNode, right: string, context: LayoutContext): Box {
|
||||
const body = layoutNode(bodyNode, context)
|
||||
const leftBox = delimiterBox(left, body.height, body.baseline, true, context.style)
|
||||
const rightBox = delimiterBox(right, body.height, body.baseline, false, context.style)
|
||||
return hpack([leftBox, body, rightBox])
|
||||
}
|
||||
|
||||
function layoutMatrix(node: Extract<MathNode, { type: "matrix" }>, context: LayoutContext): Box {
|
||||
const cellRows = node.rows.map((row) => row.map((cell) => layoutNode(cell, context)))
|
||||
const columns = node.columns?.match(/[lcr]/g)
|
||||
const rules = node.columns?.split(/[lcr]/).map((rule) => rule.length) ?? []
|
||||
const columnCount = Math.max(columns?.length ?? 0, ...cellRows.map((row) => row.length))
|
||||
const columnWidths = Array.from({ length: columnCount }, (_, column) =>
|
||||
Math.max(0, ...cellRows.map((row) => row[column]?.width ?? 0)),
|
||||
)
|
||||
const rowAscents = cellRows.map((row) => Math.max(0, ...row.map((cell) => cell.baseline)))
|
||||
const rowDescents = cellRows.map((row) => Math.max(0, ...row.map((cell) => cell.height - cell.baseline - 1)))
|
||||
const rowHeights = rowAscents.map((ascent, index) => ascent + 1 + rowDescents[index])
|
||||
const aligned = node.environment === "aligned" || node.environment === "align"
|
||||
const columnGap = node.environment === "cases" || aligned ? 2 : 1
|
||||
const gaps = Array.from({ length: columnCount + 1 }, (_, boundary) => {
|
||||
const edge = boundary === 0 || boundary === columnCount
|
||||
return rules[boundary] ? rules[boundary] + (edge ? 1 : 2) : edge ? 0 : columnGap
|
||||
})
|
||||
const width = columnWidths.reduce((sum, value) => sum + value, 0) + gaps.reduce((sum, value) => sum + value, 0)
|
||||
const height = Math.max(1, rowHeights.reduce((sum, value) => sum + value, 0) + Math.max(0, node.rows.length - 1))
|
||||
const result = blank(width, height, Math.floor(height / 2))
|
||||
let y = 0
|
||||
|
||||
for (let rowIndex = 0; rowIndex < cellRows.length; rowIndex++) {
|
||||
let x = gaps[0]
|
||||
const cells = cellRows[rowIndex]
|
||||
for (let column = 0; column < columnCount; column++) {
|
||||
const cell = cells[column]
|
||||
const columnWidth = columnWidths[column]
|
||||
if (cell) {
|
||||
const alignment =
|
||||
columns?.[column] ?? (node.environment === "cases" ? "l" : aligned ? (column % 2 === 0 ? "r" : "l") : "c")
|
||||
const cellX =
|
||||
x +
|
||||
(alignment === "l"
|
||||
? 0
|
||||
: alignment === "r"
|
||||
? columnWidth - cell.width
|
||||
: Math.floor((columnWidth - cell.width) / 2))
|
||||
const cellY = y + rowAscents[rowIndex] - cell.baseline
|
||||
overlay(result, cell, cellX, cellY)
|
||||
}
|
||||
x += columnWidth + gaps[column + 1]
|
||||
}
|
||||
y += rowHeights[rowIndex] + 1
|
||||
}
|
||||
|
||||
let boundaryX = 0
|
||||
for (let boundary = 0; boundary <= columnCount; boundary++) {
|
||||
for (let rule = 0; rule < (rules[boundary] ?? 0); rule++) {
|
||||
for (let row = 0; row < height; row++) {
|
||||
setCell(result, boundaryX + (boundary === 0 ? 0 : 1) + rule, row, "│", context.style)
|
||||
}
|
||||
}
|
||||
boundaryX += gaps[boundary] + (columnWidths[boundary] ?? 0)
|
||||
}
|
||||
|
||||
const delimiters = matrixDelimiters(node.environment)
|
||||
return delimiters
|
||||
? hpack([
|
||||
delimiterBox(delimiters[0], height, result.baseline, true, context.style),
|
||||
result,
|
||||
delimiterBox(delimiters[1], height, result.baseline, false, context.style),
|
||||
])
|
||||
: result
|
||||
}
|
||||
|
||||
function layoutBrace(node: Extract<MathNode, { type: "brace" }>, context: LayoutContext): Box {
|
||||
const body = layoutNode(node.body, context)
|
||||
const over = node.position === "over"
|
||||
const width = Math.max(3, body.width)
|
||||
const result = blank(width, body.height + 1, body.baseline + (over ? 1 : 0))
|
||||
const y = over ? 0 : body.height
|
||||
overlay(result, body, Math.floor((width - body.width) / 2), over ? 1 : 0)
|
||||
drawHorizontal(result, y, 0, width, "─", context.style)
|
||||
setCell(result, 0, y, over ? "╭" : "╰", context.style)
|
||||
setCell(result, width - 1, y, over ? "╮" : "╯", context.style)
|
||||
setCell(result, Math.floor((width - 1) / 2), y, over ? "┴" : "┬", context.style)
|
||||
return result
|
||||
}
|
||||
|
||||
function layoutAccent(
|
||||
accent: Extract<MathNode, { type: "accent" }>["accent"],
|
||||
bodyNode: MathNode,
|
||||
context: LayoutContext,
|
||||
): Box {
|
||||
const body = layoutNode(bodyNode, context)
|
||||
if (accent === "underline") {
|
||||
const result = blank(body.width, body.height + 1, body.baseline)
|
||||
overlay(result, body, 0, 0)
|
||||
drawHorizontal(result, body.height, 0, body.width, "─", context.style)
|
||||
return result
|
||||
}
|
||||
|
||||
const result = blank(body.width, body.height + 1, body.baseline + 1)
|
||||
overlay(result, body, 0, 1)
|
||||
const mark =
|
||||
accent === "hat" || accent === "widehat"
|
||||
? body.width === 1
|
||||
? "^"
|
||||
: "⌢"
|
||||
: accent === "bar" || accent === "overline"
|
||||
? "─"
|
||||
: accent === "vec"
|
||||
? "→"
|
||||
: accent === "tilde"
|
||||
? "~"
|
||||
: accent === "dot"
|
||||
? "·"
|
||||
: "¨"
|
||||
|
||||
if (accent === "bar" || accent === "overline") drawHorizontal(result, 0, 0, body.width, mark, context.style)
|
||||
else setCell(result, Math.max(0, Math.floor((body.width - cellWidth(mark)) / 2)), 0, mark, context.style)
|
||||
return result
|
||||
}
|
||||
|
||||
function delimiterBox(
|
||||
delimiter: string,
|
||||
height: number,
|
||||
baseline: number,
|
||||
left: boolean,
|
||||
style: MathStyle | undefined,
|
||||
): Box {
|
||||
if (!delimiter) return blank(0, height, baseline)
|
||||
if (height <= 1) return textBox(delimiter, style)
|
||||
const glyphs = delimiterGlyphs(delimiter)
|
||||
const width = Math.max(...glyphs.map(cellWidth))
|
||||
const result = blank(width, height, baseline)
|
||||
for (let y = 0; y < height; y++) {
|
||||
const glyph = y === 0 ? glyphs[0] : y === height - 1 ? glyphs[2] : glyphs[1]
|
||||
setCell(result, 0, y, glyph, style)
|
||||
}
|
||||
if ((delimiter === "{" || delimiter === "}") && height >= 3) {
|
||||
setCell(result, 0, Math.floor(height / 2), left ? "⎨" : "⎬", style)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function delimiterGlyphs(delimiter: string): [string, string, string] {
|
||||
switch (delimiter) {
|
||||
case "(":
|
||||
return ["⎛", "⎜", "⎝"]
|
||||
case ")":
|
||||
return ["⎞", "⎟", "⎠"]
|
||||
case "[":
|
||||
return ["⎡", "⎢", "⎣"]
|
||||
case "]":
|
||||
return ["⎤", "⎥", "⎦"]
|
||||
case "{":
|
||||
return ["⎧", "⎪", "⎩"]
|
||||
case "}":
|
||||
return ["⎫", "⎪", "⎭"]
|
||||
case "⌊":
|
||||
return ["│", "│", "⌊"]
|
||||
case "⌋":
|
||||
return ["│", "│", "⌋"]
|
||||
case "⌈":
|
||||
return ["⌈", "│", "│"]
|
||||
case "⌉":
|
||||
return ["⌉", "│", "│"]
|
||||
case "⟨":
|
||||
return ["/", "│", "\\"]
|
||||
case "⟩":
|
||||
return ["\\", "│", "/"]
|
||||
default:
|
||||
return [delimiter, delimiter, delimiter]
|
||||
}
|
||||
}
|
||||
|
||||
function matrixDelimiters(environment: string): [string, string] | undefined {
|
||||
switch (environment) {
|
||||
case "pmatrix":
|
||||
return ["(", ")"]
|
||||
case "bmatrix":
|
||||
return ["[", "]"]
|
||||
case "Bmatrix":
|
||||
return ["{", "}"]
|
||||
case "vmatrix":
|
||||
return ["│", "│"]
|
||||
case "Vmatrix":
|
||||
return ["║", "║"]
|
||||
case "cases":
|
||||
return ["{", ""]
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function hpack(boxes: Box[]): Box {
|
||||
if (boxes.length === 0) return blank(0, 1, 0)
|
||||
const ascent = Math.max(...boxes.map((box) => box.baseline))
|
||||
const descent = Math.max(...boxes.map((box) => box.height - box.baseline - 1))
|
||||
const width = boxes.reduce((sum, box) => sum + box.width, 0)
|
||||
const result = blank(width, ascent + descent + 1, ascent)
|
||||
let x = 0
|
||||
for (const box of boxes) {
|
||||
overlay(result, box, x, ascent - box.baseline)
|
||||
x += box.width
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function textBox(text: string, style?: MathStyle): Box {
|
||||
const graphemes = Array.from(graphemeSegmenter.segment(text), (item) => item.segment)
|
||||
const width = graphemes.reduce((sum, grapheme) => sum + cellWidth(grapheme), 0)
|
||||
const result = blank(width, 1, 0)
|
||||
let x = 0
|
||||
for (const grapheme of graphemes) {
|
||||
setCell(result, x, 0, grapheme, style)
|
||||
x += cellWidth(grapheme)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function blank(width: number, height: number, baseline: number): Box {
|
||||
return {
|
||||
width: Math.max(0, width),
|
||||
height: Math.max(1, height),
|
||||
baseline: Math.max(0, baseline),
|
||||
cells: Array.from({ length: Math.max(1, height) }, () => Array<MathCell | undefined>(Math.max(0, width))),
|
||||
}
|
||||
}
|
||||
|
||||
function overlay(target: Box, source: Box, x: number, y: number): void {
|
||||
for (let sourceY = 0; sourceY < source.height; sourceY++) {
|
||||
for (let sourceX = 0; sourceX < source.width; sourceX++) {
|
||||
const cell = source.cells[sourceY]?.[sourceX]
|
||||
if (cell) target.cells[y + sourceY][x + sourceX] = cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawHorizontal(
|
||||
box: Box,
|
||||
y: number,
|
||||
x: number,
|
||||
width: number,
|
||||
char: string,
|
||||
style: MathStyle | undefined,
|
||||
): void {
|
||||
for (let offset = 0; offset < width; offset++) setCell(box, x + offset, y, char, style)
|
||||
}
|
||||
|
||||
function setCell(box: Box, x: number, y: number, char: string, style?: MathStyle): void {
|
||||
if (x < 0 || y < 0 || x >= box.width || y >= box.height) return
|
||||
box.cells[y][x] = style ? { char, style } : { char }
|
||||
}
|
||||
|
||||
function nodeRole(node: MathNode): SymbolRole | undefined {
|
||||
if (node.type === "symbol") return node.role
|
||||
if (node.type === "operator") return "operator"
|
||||
// Tall constructs need a terminal-cell side bearing. Treating them like
|
||||
// operators gives their fraction bars/radical hooks breathing room without
|
||||
// adding padding inside the construct itself.
|
||||
if (node.type === "fraction" || node.type === "root" || node.type === "matrix") return "operator"
|
||||
if (node.type === "scripts") return nodeRole(node.base)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function needsMathSpace(previous: SymbolRole | undefined, current: SymbolRole | undefined, count: number): boolean {
|
||||
if (count === 0) return false
|
||||
if (previous === "punctuation" || previous === "opening" || current === "punctuation" || current === "closing") {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
previous === "binary" ||
|
||||
previous === "relation" ||
|
||||
previous === "operator" ||
|
||||
current === "binary" ||
|
||||
current === "relation" ||
|
||||
current === "operator"
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeBinaryRole(
|
||||
role: SymbolRole | undefined,
|
||||
previous: SymbolRole | undefined,
|
||||
next: SymbolRole | undefined,
|
||||
): SymbolRole | undefined {
|
||||
if (role !== "binary") return role
|
||||
if (
|
||||
previous === undefined ||
|
||||
previous === "binary" ||
|
||||
previous === "relation" ||
|
||||
previous === "operator" ||
|
||||
previous === "punctuation" ||
|
||||
previous === "opening" ||
|
||||
next === undefined ||
|
||||
next === "binary" ||
|
||||
next === "relation" ||
|
||||
next === "punctuation" ||
|
||||
next === "closing"
|
||||
) {
|
||||
return "ordinary"
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
function nextSignificantRole(nodes: MathNode[], start: number): SymbolRole | undefined {
|
||||
for (let index = start; index < nodes.length; index++) {
|
||||
const node = nodes[index]
|
||||
if (node.type === "space") continue
|
||||
return nodeRole(node) ?? "ordinary"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function simpleNodeText(node: MathNode): string | undefined {
|
||||
if (node.type === "symbol" || node.type === "text" || node.type === "operator") return node.value
|
||||
if (node.type === "row") {
|
||||
const values = node.body.map(simpleNodeText)
|
||||
return values.every((value) => value !== undefined) ? values.join("") : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function mapScript(value: string | undefined, table: Readonly<Record<string, string>>): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
let result = ""
|
||||
for (const char of value) {
|
||||
const mapped = table[char]
|
||||
if (!mapped) return undefined
|
||||
result += mapped
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function withVariant(context: LayoutContext, variant: MathVariant): LayoutContext {
|
||||
const style = variant === "bold" ? { bold: true } : variant === "italic" ? { italic: true } : {}
|
||||
return { ...context, variant, style: { ...context.style, ...style } }
|
||||
}
|
||||
|
||||
function applyVariant(value: string, variant: MathVariant | undefined): string {
|
||||
if (!variant || variant === "normal" || variant === "bold" || variant === "italic") return value
|
||||
|
||||
const exceptions: Partial<Record<MathVariant, Readonly<Record<string, string>>>> = {
|
||||
"double-struck": {
|
||||
C: "ℂ",
|
||||
H: "ℍ",
|
||||
N: "ℕ",
|
||||
P: "ℙ",
|
||||
Q: "ℚ",
|
||||
R: "ℝ",
|
||||
Z: "ℤ",
|
||||
},
|
||||
script: {
|
||||
B: "ℬ",
|
||||
E: "ℰ",
|
||||
F: "ℱ",
|
||||
H: "ℋ",
|
||||
I: "ℐ",
|
||||
L: "ℒ",
|
||||
M: "ℳ",
|
||||
R: "ℛ",
|
||||
e: "ℯ",
|
||||
g: "ℊ",
|
||||
o: "ℴ",
|
||||
},
|
||||
fraktur: {
|
||||
C: "ℭ",
|
||||
H: "ℌ",
|
||||
I: "ℑ",
|
||||
R: "ℜ",
|
||||
Z: "ℨ",
|
||||
},
|
||||
}
|
||||
|
||||
const ranges: Partial<Record<MathVariant, readonly [number, number, number?]>> = {
|
||||
"double-struck": [0x1d538, 0x1d552, 0x1d7d8],
|
||||
script: [0x1d49c, 0x1d4b6],
|
||||
fraktur: [0x1d504, 0x1d51e],
|
||||
sans: [0x1d5a0, 0x1d5ba, 0x1d7e2],
|
||||
monospace: [0x1d670, 0x1d68a, 0x1d7f6],
|
||||
}
|
||||
const range = ranges[variant]
|
||||
if (!range) return value
|
||||
|
||||
return Array.from(value)
|
||||
.map((char) => {
|
||||
const exception = exceptions[variant]?.[char]
|
||||
if (exception) return exception
|
||||
const code = char.codePointAt(0)!
|
||||
if (code >= 65 && code <= 90) return String.fromCodePoint(range[0] + code - 65)
|
||||
if (code >= 97 && code <= 122) return String.fromCodePoint(range[1] + code - 97)
|
||||
if (range[2] !== undefined && code >= 48 && code <= 57) return String.fromCodePoint(range[2] + code - 48)
|
||||
return char
|
||||
})
|
||||
.join("")
|
||||
}
|
||||
|
||||
function cellWidth(value: string): number {
|
||||
if (value.length === 0) return 0
|
||||
if (/^(?:[\u0000-\u001f\u007f-\u009f]|[\u0300-\u036f]|[\ufe00-\ufe0f])$/u.test(value)) return 0
|
||||
const code = value.codePointAt(0) ?? 0
|
||||
if (
|
||||
code >= 0x1100 &&
|
||||
(code <= 0x115f ||
|
||||
code === 0x2329 ||
|
||||
code === 0x232a ||
|
||||
(code >= 0x2e80 && code <= 0xa4cf) ||
|
||||
(code >= 0xac00 && code <= 0xd7a3) ||
|
||||
(code >= 0xf900 && code <= 0xfaff) ||
|
||||
(code >= 0xfe10 && code <= 0xfe6f) ||
|
||||
(code >= 0xff00 && code <= 0xff60) ||
|
||||
(code >= 0xffe0 && code <= 0xffe6) ||
|
||||
(code >= 0x1f300 && code <= 0x1faff))
|
||||
) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
function asPublicLayout(box: Box): MathLayout {
|
||||
return {
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
baseline: box.baseline,
|
||||
cells: box.cells,
|
||||
toString() {
|
||||
return box.cells
|
||||
.map((row) => {
|
||||
let output = ""
|
||||
for (let x = 0; x < box.width; x++) {
|
||||
const cell = row[x]
|
||||
output += cell?.char ?? " "
|
||||
if (cell && cellWidth(cell.char) > 1) x += cellWidth(cell.char) - 1
|
||||
}
|
||||
return output.trimEnd()
|
||||
})
|
||||
.join("\n")
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { LatexParseError } from "./types"
|
||||
|
||||
export const DEFAULT_MAX_SOURCE_LENGTH = 100_000
|
||||
export const DEFAULT_MAX_NESTING_DEPTH = 256
|
||||
|
||||
export function resolvePositiveInteger(value: number | undefined, fallback: number, optionName: string): number {
|
||||
if (value === undefined) return fallback
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
throw new RangeError(`${optionName} must be a positive safe integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function assertSourceLength(source: string, maximum: number, label = "LaTeX source"): void {
|
||||
if (source.length > maximum) {
|
||||
throw new LatexParseError(`${label} exceeds the ${maximum}-character limit`, maximum)
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNestingDepth(source: string, maximum: number): void {
|
||||
let depth = 0
|
||||
let slashRun = 0
|
||||
for (let index = 0; index < source.length; index++) {
|
||||
const char = source[index]
|
||||
if (char === "\\") {
|
||||
slashRun++
|
||||
continue
|
||||
}
|
||||
const escaped = slashRun % 2 === 1
|
||||
slashRun = 0
|
||||
if (char === "{" && !escaped) {
|
||||
depth++
|
||||
if (depth > maximum) {
|
||||
throw new LatexParseError(`LaTeX nesting exceeds the ${maximum}-level limit`, index)
|
||||
}
|
||||
} else if (char === "}" && !escaped) {
|
||||
depth = Math.max(0, depth - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import {
|
||||
CodeRenderable,
|
||||
MarkdownRenderable,
|
||||
RGBA,
|
||||
ScrollBoxRenderable,
|
||||
SyntaxStyle,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
createMarkdownCodeBlockRenderer,
|
||||
} from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { renderLatex } from "./render"
|
||||
import { createLatexCodeBlockRenderer } from "./markdown"
|
||||
|
||||
const renderers: Awaited<ReturnType<typeof createTestRenderer>>["renderer"][] = []
|
||||
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })
|
||||
|
||||
afterEach(() => {
|
||||
renderers.splice(0).forEach((renderer) => renderer.destroy())
|
||||
})
|
||||
|
||||
async function setup(content: string, width = 80) {
|
||||
const output = await createTestRenderer({
|
||||
width,
|
||||
height: 24,
|
||||
remote: true,
|
||||
useThread: false,
|
||||
})
|
||||
renderers.push(output.renderer)
|
||||
const palette = { text: "#abcdef", subdued: "#667788" }
|
||||
const render = createLatexCodeBlockRenderer(output.renderer, () => palette)
|
||||
const markdown = new MarkdownRenderable(output.renderer, {
|
||||
content,
|
||||
syntaxStyle,
|
||||
streaming: true,
|
||||
internalBlockMode: "top-level",
|
||||
renderNode: createMarkdownCodeBlockRenderer({ latex: render, math: render }),
|
||||
})
|
||||
output.renderer.root.add(markdown)
|
||||
await output.renderOnce()
|
||||
return { ...output, markdown, palette }
|
||||
}
|
||||
|
||||
test.each(["latex", "math", "tex", "LATEX title=example"])("renders a %s fence", async (language) => {
|
||||
const output = await setup(`\`\`\`${language}\n\\frac{1}{2}\n\`\`\``)
|
||||
const formula = output.markdown.getChildren()[0]?.getChildren()[0]
|
||||
expect(formula).toBeInstanceOf(TextRenderable)
|
||||
if (!(formula instanceof TextRenderable)) throw new Error("Expected a formula")
|
||||
expect(formula.height).toBe(3)
|
||||
expect(formula.chunks.find((chunk) => chunk.text === "1")?.fg?.equals(RGBA.fromHex("#abcdef"))).toBe(true)
|
||||
expect(output.captureCharFrame()).toContain("1")
|
||||
expect(output.captureCharFrame()).toContain("2")
|
||||
expect(output.captureCharFrame()).not.toContain("\\frac")
|
||||
})
|
||||
|
||||
test.each([
|
||||
String.raw`\frac{1}{`,
|
||||
String.raw`\unsupported{x}`,
|
||||
String.raw`\cfrac[x]{1}{2}`,
|
||||
String.raw`\left\unknown x\right)`,
|
||||
String.raw`\begin{array}{p{2cm}}x\end{array}`,
|
||||
String.raw`\documentclass{article}
|
||||
\begin{document}
|
||||
Hello
|
||||
\end{document}`,
|
||||
])("preserves invalid or unsupported math as source: %s", async (source) => {
|
||||
const output = await setup(`\`\`\`latex\n${source}\n\`\`\``)
|
||||
const block = output.markdown.getChildren()[0]
|
||||
expect(block).toBeInstanceOf(CodeRenderable)
|
||||
if (!(block instanceof CodeRenderable)) throw new Error("Expected source fallback")
|
||||
expect(block.content).toBe(source)
|
||||
})
|
||||
|
||||
test.each([
|
||||
String.raw`\sqrt[\frac{1}{2}]{x}`,
|
||||
String.raw`\left\|v\right\|`,
|
||||
String.raw`\left(A\rightarrow B\right)`,
|
||||
String.raw`\begin{aligned}a&=b+c\\&=d\end{aligned}`,
|
||||
String.raw`\displaylines{x=1\\y=2}`,
|
||||
String.raw`\cfrac[l]{1}{12345}`,
|
||||
String.raw`\underbrace{a+b+c}_{n}`,
|
||||
String.raw`\begin{array}{l|r}a&wide\\long&b\end{array}`,
|
||||
])("renders structured math through the Markdown adapter: %s", async (source) => {
|
||||
const output = await setup(`\`\`\`latex\n${source}\n\`\`\``)
|
||||
expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable)
|
||||
expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable)
|
||||
expect(output.captureCharFrame()).not.toContain("\\")
|
||||
})
|
||||
|
||||
test("renders the next valid formula after an incomplete streaming prefix", async () => {
|
||||
const output = await setup("```latex\n\\frac{1}{")
|
||||
expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable)
|
||||
|
||||
output.markdown.content += "2}"
|
||||
await output.renderOnce()
|
||||
expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable)
|
||||
expect(output.captureCharFrame()).not.toContain("\\frac")
|
||||
|
||||
output.markdown.content += "\n```"
|
||||
output.markdown.streaming = false
|
||||
await output.renderOnce()
|
||||
expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable)
|
||||
})
|
||||
|
||||
test("renders the final formula when the last text update is applied before completion", async () => {
|
||||
const output = await setup("```latex\n\\frac{1}{")
|
||||
output.markdown.content += "2}\n```"
|
||||
output.markdown.streaming = false
|
||||
await output.renderOnce()
|
||||
expect(output.markdown.getChildren().filter((child) => child instanceof ScrollBoxRenderable).length).toBe(1)
|
||||
expect(output.captureCharFrame()).not.toContain("\\frac")
|
||||
})
|
||||
|
||||
test("retains the last valid Unicode formula while the next fraction is incomplete", async () => {
|
||||
const output = await setup("```latex\n\\frac{a_1+b_1}{c_1+d_1}")
|
||||
const previous = output.captureCharFrame()
|
||||
output.markdown.content += "+\\frac{a_"
|
||||
await output.renderOnce()
|
||||
expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable)
|
||||
expect(output.captureCharFrame()).toBe(previous)
|
||||
|
||||
output.markdown.content += "2+b_2}{c_2+d_2}"
|
||||
await output.renderOnce()
|
||||
expect(output.captureCharFrame()).not.toBe(previous)
|
||||
expect(output.captureCharFrame()).not.toContain("\\frac")
|
||||
})
|
||||
|
||||
test.each(["close", "stop"])("discards an incomplete preview when the stream ends: %s", async (end) => {
|
||||
const output = await setup("```latex\nx^2")
|
||||
output.markdown.content += " + \\frac{1}{"
|
||||
await output.renderOnce()
|
||||
expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable)
|
||||
|
||||
if (end === "close") output.markdown.content += "\n```"
|
||||
if (end === "stop") output.markdown.streaming = false
|
||||
await output.renderOnce()
|
||||
expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable)
|
||||
})
|
||||
|
||||
test("does not reuse another fence's preview or keep a removed fence's preview", async () => {
|
||||
const output = await setup("```latex\nx^2\n```\n\n```latex\n\\frac{1}{")
|
||||
expect(output.markdown.getChildren()[1]).toBeInstanceOf(CodeRenderable)
|
||||
|
||||
output.markdown.content = ""
|
||||
await output.renderOnce()
|
||||
output.markdown.content = "```latex\nx^2 + \\frac{1}{"
|
||||
await output.renderOnce()
|
||||
expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable)
|
||||
})
|
||||
|
||||
test("does not leave a stale formula when a stream ends with invalid math", async () => {
|
||||
const output = await setup("```latex\nx^2")
|
||||
expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable)
|
||||
|
||||
output.markdown.content += " + \\unsupported{x}\n```"
|
||||
output.markdown.streaming = false
|
||||
await output.renderOnce()
|
||||
expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable)
|
||||
})
|
||||
|
||||
test("keeps a matrix and surrounding Markdown intact in a narrow terminal", async () => {
|
||||
const output = await setup("Before\n\n```math\n\\begin{pmatrix}a & b \\\\ c & d\\end{pmatrix}\n```\n\nAfter", 32)
|
||||
await output.renderOnce()
|
||||
const frame = output.captureCharFrame()
|
||||
expect(frame).toContain("Before")
|
||||
expect(frame).toContain("a b")
|
||||
expect(frame).toContain("c d")
|
||||
expect(frame).toContain("After")
|
||||
expect(frame).not.toContain("pmatrix")
|
||||
})
|
||||
|
||||
test("leaves ordinary code fences alone", async () => {
|
||||
const output = await setup("```typescript\nconst x = 2\n```")
|
||||
expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable)
|
||||
})
|
||||
|
||||
test("allows wide formulas to scroll horizontally without wrapping", async () => {
|
||||
const output = await setup(
|
||||
"```latex\n\\text{Start a very long formula with enough content to overflow Finish}\n```",
|
||||
24,
|
||||
)
|
||||
const viewport = output.markdown.getChildren()[0]
|
||||
expect(viewport).toBeInstanceOf(ScrollBoxRenderable)
|
||||
if (!(viewport instanceof ScrollBoxRenderable)) throw new Error("Expected a horizontal viewport")
|
||||
expect(output.captureCharFrame()).toContain("Start")
|
||||
expect(output.captureCharFrame()).not.toContain("Finish")
|
||||
expect(viewport.height).toBe(1)
|
||||
|
||||
await output.mockMouse.scroll(2, 1, "right")
|
||||
await output.renderOnce()
|
||||
expect(viewport.scrollLeft).toBeGreaterThan(0)
|
||||
|
||||
viewport.scrollLeft = viewport.scrollWidth
|
||||
await output.renderOnce()
|
||||
expect(output.captureCharFrame()).toContain("Finish")
|
||||
expect(output.captureCharFrame()).not.toContain("Start")
|
||||
})
|
||||
|
||||
test("subdues structure and emphasizes relations using the theme", async () => {
|
||||
const output = await setup("```latex\nx=\\sqrt{\\frac{1}{2}}\n```")
|
||||
const formula = output.markdown.getChildren()[0]?.getChildren()[0]
|
||||
if (!(formula instanceof TextRenderable)) throw new Error("Expected Unicode math")
|
||||
for (const mark of ["\u2500", "\u2502", "\u256d", "\u256f", "\u2570"]) {
|
||||
expect(formula.chunks.find((chunk) => chunk.text === mark)?.fg?.equals(RGBA.fromHex(output.palette.subdued))).toBe(
|
||||
true,
|
||||
)
|
||||
}
|
||||
expect(formula.chunks.find((chunk) => chunk.text === "x")?.fg?.equals(RGBA.fromHex(output.palette.text))).toBe(true)
|
||||
expect(formula.chunks.find((chunk) => chunk.text === "=")?.attributes).toBe(TextAttributes.BOLD)
|
||||
|
||||
output.palette.text = "#123456"
|
||||
output.palette.subdued = "#789abc"
|
||||
output.markdown.refreshStyles()
|
||||
await output.renderOnce()
|
||||
const updated = output.markdown.getChildren()[0]?.getChildren()[0]
|
||||
if (!(updated instanceof TextRenderable)) throw new Error("Expected Unicode math")
|
||||
expect(updated.chunks.find((chunk) => chunk.text === "x")?.fg?.equals(RGBA.fromHex(output.palette.text))).toBe(true)
|
||||
for (const mark of ["\u2500", "\u2502", "\u256d", "\u256f", "\u2570"]) {
|
||||
expect(updated.chunks.find((chunk) => chunk.text === mark)?.fg?.equals(RGBA.fromHex(output.palette.subdued))).toBe(
|
||||
true,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test.each([String.raw`\text{${"\u4e2d\u6587"}}=x`, String.raw`\frac{\text{${"\u4e2d\u6587"}}}{abcd}=x`])(
|
||||
"preserves wide-character alignment: %s",
|
||||
async (source) => {
|
||||
const layout = renderLatex(source)
|
||||
const output = await setup(`\`\`\`latex\n${source}\n\`\`\``, layout.width)
|
||||
const viewport = output.markdown.getChildren()[0]
|
||||
if (!(viewport instanceof ScrollBoxRenderable)) throw new Error("Expected math viewport")
|
||||
const formula = viewport.getChildren()[0]
|
||||
if (!(formula instanceof TextRenderable)) throw new Error("Expected Unicode math")
|
||||
expect(
|
||||
formula.chunks
|
||||
.map((chunk) => chunk.text)
|
||||
.join("")
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n"),
|
||||
).toBe(layout.toString())
|
||||
expect(viewport.scrollWidth).toBe(layout.width)
|
||||
},
|
||||
)
|
||||
@@ -1,120 +0,0 @@
|
||||
import {
|
||||
CodeRenderable,
|
||||
RenderableEvents,
|
||||
ScrollBoxRenderable,
|
||||
StyledText,
|
||||
TextRenderable,
|
||||
createTextAttributes,
|
||||
parseColor,
|
||||
type ColorInput,
|
||||
type MarkdownCodeBlockRenderer,
|
||||
type RenderContext,
|
||||
} from "@opentui/core"
|
||||
import stringWidth from "string-width"
|
||||
import { renderLatex } from "./render"
|
||||
import { LatexParseError, type MathLayout } from "./types"
|
||||
|
||||
export type LatexOptions = {
|
||||
text: ColorInput
|
||||
subdued: ColorInput
|
||||
}
|
||||
|
||||
type LatexFrame = {
|
||||
source: string
|
||||
layout: MathLayout
|
||||
}
|
||||
|
||||
export function createLatexCodeBlockRenderer(
|
||||
context: RenderContext,
|
||||
options: () => LatexOptions,
|
||||
): MarkdownCodeBlockRenderer {
|
||||
const lastGood = new Map<string, LatexFrame>()
|
||||
return (token, render) => {
|
||||
const fallback = render.defaultRender()
|
||||
const key = fallback?.id
|
||||
const previous = key ? lastGood.get(key) : undefined
|
||||
const retained = previous && token.text.startsWith(previous.source) ? previous : undefined
|
||||
const fence = /^ {0,3}(`{3,}|~{3,})/.exec(token.raw)?.[1]
|
||||
const streaming =
|
||||
fallback instanceof CodeRenderable &&
|
||||
fallback.streaming &&
|
||||
fence &&
|
||||
!new RegExp(`\\n {0,3}${fence[0]}{${fence.length},}\\s*$`).test(token.raw)
|
||||
const layout = layoutLatex(token.text)
|
||||
const frame: LatexFrame | undefined = layout
|
||||
? { source: token.text, layout }
|
||||
: streaming && retained
|
||||
? { ...retained }
|
||||
: undefined
|
||||
if (!frame) return fallback ?? undefined
|
||||
const palette = options()
|
||||
const text = parseColor(palette.text)
|
||||
const subdued = parseColor(palette.subdued)
|
||||
const formula = new TextRenderable(context, {
|
||||
content: new StyledText(
|
||||
frame.layout.cells.flatMap((row, index) => [
|
||||
...Array.from(row).flatMap((cell, column) => {
|
||||
// Wide glyphs already occupy the following cell; do not emit another space for it.
|
||||
if (column > 0 && stringWidth(row[column - 1]?.char ?? "") > 1) return []
|
||||
return [
|
||||
{
|
||||
__isChunk: true as const,
|
||||
text: cell?.char ?? " ",
|
||||
fg: /^[()[\]{}|\u221a\u239b-\u23ad\u2500-\u257f]$/u.test(cell?.char ?? "") ? subdued : text,
|
||||
attributes: createTextAttributes({
|
||||
bold: cell?.style?.bold || /^[=<>\u2260\u2261\u2264\u2265\u2248]$/u.test(cell?.char ?? ""),
|
||||
italic: cell?.style?.italic,
|
||||
dim: cell?.style?.dim,
|
||||
}),
|
||||
},
|
||||
]
|
||||
}),
|
||||
...(index < frame.layout.height - 1 ? [{ __isChunk: true as const, text: "\n", fg: text }] : []),
|
||||
]),
|
||||
),
|
||||
width: "100%",
|
||||
minWidth: frame.layout.width,
|
||||
height: frame.layout.height,
|
||||
wrapMode: "none",
|
||||
selectable: false,
|
||||
flexShrink: 0,
|
||||
})
|
||||
const viewport = new ScrollBoxRenderable(context, {
|
||||
width: "100%",
|
||||
height: frame.layout.height,
|
||||
flexShrink: 0,
|
||||
marginTop: 1,
|
||||
scrollX: true,
|
||||
scrollY: false,
|
||||
onMouseScroll(event) {
|
||||
if (event.modifiers.shift || event.scroll?.direction === "left" || event.scroll?.direction === "right") {
|
||||
event.stopPropagation()
|
||||
}
|
||||
},
|
||||
})
|
||||
// The setters opt out of automatic scrollbar visibility; constructor options do not.
|
||||
viewport.horizontalScrollBar.visible = false
|
||||
viewport.verticalScrollBar.visible = false
|
||||
viewport.add(formula)
|
||||
if (key) {
|
||||
lastGood.set(key, frame)
|
||||
viewport.once(RenderableEvents.DESTROYED, () => {
|
||||
// Markdown destroys the old block before constructing its replacement in the same stack.
|
||||
queueMicrotask(() => {
|
||||
if (lastGood.get(key) === frame) lastGood.delete(key)
|
||||
})
|
||||
})
|
||||
}
|
||||
return viewport
|
||||
}
|
||||
}
|
||||
|
||||
function layoutLatex(source: string) {
|
||||
try {
|
||||
return renderLatex(source, { strict: true, displayMode: true })
|
||||
} catch (error) {
|
||||
// Preserve the exact source for incomplete math, unsupported commands, and oversized input.
|
||||
if (error instanceof LatexParseError || error instanceof RangeError) return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { renderLatexToString } from "./render"
|
||||
|
||||
describe("parser rendering regressions", () => {
|
||||
test.each([
|
||||
[String.raw`\|v\|`, "║v║"],
|
||||
[String.raw`\left\|v\right\|`, "║v║"],
|
||||
[String.raw`\left|v\right|`, "│v│"],
|
||||
[String.raw`\left(A\rightarrow B\right)`, "(A → B)"],
|
||||
[String.raw`\left\lbrace x\right\rbrace`, "{x}"],
|
||||
[String.raw`\operatorname{arg\,max} x`, "arg max x"],
|
||||
[String.raw`\textrm{if }x`, "if x"],
|
||||
[String.raw`\displaylines{x=1\\y=2}`, "x = 1\n\ny = 2"],
|
||||
])("renders supported syntax without leaking or losing tokens: %s", (source, expected) => {
|
||||
expect(renderLatexToString(source, { strict: true })).toBe(expected)
|
||||
})
|
||||
|
||||
test("renders empty aligned cells like explicitly empty groups", () => {
|
||||
expect(renderLatexToString(String.raw`\begin{aligned}&=x\\&=y\end{aligned}`, { strict: true })).toBe(
|
||||
renderLatexToString(String.raw`\begin{aligned}{}&=x\\{}&=y\end{aligned}`, { strict: true }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,273 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseLatex } from "./parser"
|
||||
import { LatexParseError } from "./types"
|
||||
|
||||
describe("parseLatex", () => {
|
||||
test("parses fractions and scripts structurally", () => {
|
||||
expect(parseLatex(String.raw`\frac{x^2+1}{y_0}`)).toMatchObject({
|
||||
type: "fraction",
|
||||
bar: true,
|
||||
numerator: { type: "row" },
|
||||
denominator: { type: "scripts" },
|
||||
})
|
||||
})
|
||||
|
||||
test("parses matrix environments into rows and cells", () => {
|
||||
expect(parseLatex(String.raw`\begin{pmatrix}a & b \\ c & d\end{pmatrix}`)).toMatchObject({
|
||||
type: "matrix",
|
||||
environment: "pmatrix",
|
||||
rows: [
|
||||
[
|
||||
{ type: "symbol", value: "a" },
|
||||
{ type: "symbol", value: "b" },
|
||||
],
|
||||
[
|
||||
{ type: "symbol", value: "c" },
|
||||
{ type: "symbol", value: "d" },
|
||||
],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("accepts array column specs and starred alignment environments", () => {
|
||||
expect(parseLatex(String.raw`\begin{array}{cc}a & b \\ c & d\end{array}`)).toMatchObject({
|
||||
type: "matrix",
|
||||
environment: "array",
|
||||
columns: "cc",
|
||||
rows: [
|
||||
[{}, {}],
|
||||
[{}, {}],
|
||||
],
|
||||
})
|
||||
expect(parseLatex(String.raw`\begin{align*}a &= b \\ c &= d\end{align*}`)).toMatchObject({
|
||||
type: "matrix",
|
||||
environment: "align",
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves double norm delimiters without changing single bars", () => {
|
||||
expect(parseLatex(String.raw`\|v\|`, { strict: true })).toMatchObject({
|
||||
type: "row",
|
||||
body: [{ value: "║" }, { value: "v" }, { value: "║" }],
|
||||
})
|
||||
expect(parseLatex(String.raw`\left\|v\right\|`, { strict: true })).toMatchObject({
|
||||
type: "delimited",
|
||||
left: "║",
|
||||
right: "║",
|
||||
})
|
||||
expect(parseLatex(String.raw`\left|v\right|`, { strict: true })).toMatchObject({
|
||||
type: "delimited",
|
||||
left: "│",
|
||||
right: "│",
|
||||
})
|
||||
})
|
||||
|
||||
test("matches the whole right command and keeps nested delimiters", () => {
|
||||
expect(parseLatex(String.raw`\left(A\rightarrow B\right)`, { strict: true })).toMatchObject({
|
||||
type: "delimited",
|
||||
left: "(",
|
||||
body: { type: "row", body: [{ value: "A" }, { value: "→" }, { value: "B" }] },
|
||||
right: ")",
|
||||
})
|
||||
expect(parseLatex(String.raw`\left(\left[A\right]\rightharpoonup B\right)`)).toMatchObject({
|
||||
type: "delimited",
|
||||
body: { type: "row", body: [{ type: "delimited" }, { value: "⇀" }, { value: "B" }] },
|
||||
})
|
||||
expect(() => parseLatex(String.raw`\left(A\rightarrow B`, { strict: true })).toThrow(/Missing \\right/)
|
||||
expect(() => parseLatex(String.raw`\left(A\rightward B\right)`, { strict: true })).toThrow(
|
||||
/Unsupported command \\rightward/,
|
||||
)
|
||||
})
|
||||
|
||||
test("accepts empty leading, interior, and trailing environment cells", () => {
|
||||
expect(parseLatex(String.raw`\begin{aligned}&=x\\&=y\end{aligned}`, { strict: true })).toMatchObject({
|
||||
type: "matrix",
|
||||
environment: "aligned",
|
||||
rows: [
|
||||
[
|
||||
{ type: "row", body: [] },
|
||||
{ type: "row", body: [{ value: "=" }, { value: "x" }] },
|
||||
],
|
||||
[
|
||||
{ type: "row", body: [] },
|
||||
{ type: "row", body: [{ value: "=" }, { value: "y" }] },
|
||||
],
|
||||
],
|
||||
})
|
||||
expect(parseLatex(String.raw`\begin{matrix}a&&\\&b&\end{matrix}`, { strict: true })).toMatchObject({
|
||||
type: "matrix",
|
||||
rows: [
|
||||
[{ value: "a" }, { type: "row", body: [] }, { type: "row", body: [] }],
|
||||
[{ type: "row", body: [] }, { value: "b" }, { type: "row", body: [] }],
|
||||
],
|
||||
})
|
||||
expect(parseLatex(String.raw`\begin{matrix}a\\\end{matrix}`)).toMatchObject({ rows: [[{ value: "a" }]] })
|
||||
expect(parseLatex(String.raw`\begin{matrix}\\\end{matrix}`)).toMatchObject({ rows: [[{ type: "row", body: [] }]] })
|
||||
})
|
||||
|
||||
test("parses displaylines as separate gathered rows", () => {
|
||||
expect(parseLatex(String.raw`\displaylines{x=1\\y=2}`, { strict: true })).toMatchObject({
|
||||
type: "matrix",
|
||||
environment: "gathered",
|
||||
rows: [
|
||||
[{ type: "row", body: [{ value: "x" }, { value: "=" }, { value: "1" }] }],
|
||||
[{ type: "row", body: [{ value: "y" }, { value: "=" }, { value: "2" }] }],
|
||||
],
|
||||
})
|
||||
expect(parseLatex(String.raw`\displaylines{\frac{1}{2}\\{y}}+z`)).toMatchObject({
|
||||
type: "row",
|
||||
body: [{ type: "matrix", rows: [[{ type: "fraction" }], [{ value: "y" }]] }, { value: "+" }, { value: "z" }],
|
||||
})
|
||||
expect(() => parseLatex(String.raw`\displaylines[l]{x\\y}`, { strict: true })).toThrow(LatexParseError)
|
||||
expect(() => parseLatex(String.raw`\displaylines{x\\y`, { strict: true })).toThrow(LatexParseError)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["", undefined],
|
||||
["[]", undefined],
|
||||
["[l]", "left"],
|
||||
["[r]", "right"],
|
||||
] as const)("parses continued fraction alignment %s before its arguments", (option, numeratorAlign) => {
|
||||
expect(parseLatex(String.raw`\cfrac${option}{1}{23}`, { strict: true })).toEqual({
|
||||
type: "fraction",
|
||||
numerator: { type: "symbol", value: "1", role: "ordinary" },
|
||||
denominator: {
|
||||
type: "row",
|
||||
body: [
|
||||
{ type: "symbol", value: "2", role: "ordinary" },
|
||||
{ type: "symbol", value: "3", role: "ordinary" },
|
||||
],
|
||||
},
|
||||
bar: true,
|
||||
...(numeratorAlign ? { numeratorAlign } : {}),
|
||||
})
|
||||
})
|
||||
|
||||
test.each(["[c]", "[lr]", "[left]", "[l"])("rejects unsupported continued fraction alignment %s", (option) => {
|
||||
expect(() => parseLatex(String.raw`\cfrac${option}{1}{2}`, { strict: true })).toThrow(LatexParseError)
|
||||
})
|
||||
|
||||
test("retains normalized array columns including edge and double rules", () => {
|
||||
expect(parseLatex(String.raw`\begin{array}{ | l || c r | }a&b&c\end{array}`, { strict: true })).toMatchObject({
|
||||
type: "matrix",
|
||||
environment: "array",
|
||||
columns: "|l||cr|",
|
||||
})
|
||||
})
|
||||
|
||||
test.each(["", "||", "p{2cm}", "*{2}{c}", "c@{}c", "lXr"])("rejects unsupported array columns %s", (columns) => {
|
||||
expect(() => parseLatex(String.raw`\begin{array}{${columns}}a\end{array}`, { strict: true })).toThrow(
|
||||
LatexParseError,
|
||||
)
|
||||
})
|
||||
|
||||
test("requires an array column specification", () => {
|
||||
expect(() => parseLatex(String.raw`\begin{array}a&b\end{array}`, { strict: true })).toThrow(LatexParseError)
|
||||
})
|
||||
|
||||
test("emits structural braces while keeping annotations as scripts", () => {
|
||||
expect(parseLatex(String.raw`\overbrace{a+b}^{n}`, { strict: true })).toMatchObject({
|
||||
type: "scripts",
|
||||
base: { type: "brace", position: "over", body: { type: "row" } },
|
||||
superscript: { value: "n" },
|
||||
})
|
||||
expect(parseLatex(String.raw`\underbrace{x}_{k}`, { strict: true })).toMatchObject({
|
||||
type: "scripts",
|
||||
base: { type: "brace", position: "under", body: { value: "x" } },
|
||||
subscript: { value: "k" },
|
||||
})
|
||||
})
|
||||
|
||||
test("recognizes named braces and rejects unsupported delimiter commands in strict mode", () => {
|
||||
expect(parseLatex(String.raw`\left\lbrace x\right\rbrace`, { strict: true })).toMatchObject({
|
||||
type: "delimited",
|
||||
left: "{",
|
||||
right: "}",
|
||||
})
|
||||
expect(parseLatex(String.raw`\lbrace x\rbrace`, { strict: true })).toMatchObject({
|
||||
type: "row",
|
||||
body: [{ value: "{" }, { value: "x" }, { value: "}" }],
|
||||
})
|
||||
for (const source of [
|
||||
String.raw`\left\unknown x\right)`,
|
||||
String.raw`\left(x\right\unknown`,
|
||||
String.raw`\big\unknown`,
|
||||
String.raw`\left(x\middle\unknown y\right)`,
|
||||
]) {
|
||||
expect(() => parseLatex(source, { strict: true })).toThrow(/Unsupported delimiter \\unknown/)
|
||||
}
|
||||
})
|
||||
|
||||
test("expands user macros", () => {
|
||||
expect(parseLatex(String.raw`\R \to \R`, { macros: { "\\R": String.raw`\mathbb{R}` } })).toMatchObject({
|
||||
type: "row",
|
||||
})
|
||||
})
|
||||
|
||||
test("reports useful strict-mode errors", () => {
|
||||
expect(() => parseLatex(String.raw`\definitelyUnknown{x}`, { strict: true })).toThrow(LatexParseError)
|
||||
})
|
||||
|
||||
test("keeps escaped braces inside raw text groups", () => {
|
||||
expect(parseLatex(String.raw`\text{left \{ only}`)).toMatchObject({
|
||||
type: "text",
|
||||
value: "left { only",
|
||||
})
|
||||
expect(parseLatex(String.raw`\text{right \} only}`)).toMatchObject({
|
||||
type: "text",
|
||||
value: "right } only",
|
||||
})
|
||||
})
|
||||
|
||||
test("supports starred named operators and limits modifiers", () => {
|
||||
expect(parseLatex(String.raw`\operatorname*{arg\,max}_{x}`)).toMatchObject({
|
||||
type: "scripts",
|
||||
base: { type: "operator", value: "arg max", limits: true },
|
||||
})
|
||||
expect(parseLatex(String.raw`\int\limits_0^1`)).toMatchObject({
|
||||
type: "scripts",
|
||||
base: { type: "operator", value: "∫", limits: true },
|
||||
})
|
||||
expect(parseLatex(String.raw`\sum\nolimits_{i=1}`)).toMatchObject({
|
||||
type: "scripts",
|
||||
base: { type: "operator", value: "∑", limits: false },
|
||||
})
|
||||
})
|
||||
|
||||
test("interprets operator spacing and preserves roman text whitespace", () => {
|
||||
expect(parseLatex(String.raw`\operatorname{arg\,max}`, { strict: true })).toEqual({
|
||||
type: "operator",
|
||||
value: "arg max",
|
||||
limits: false,
|
||||
})
|
||||
expect(parseLatex(String.raw`\textrm{ if }`, { strict: true })).toEqual({
|
||||
type: "variant",
|
||||
variant: "normal",
|
||||
body: { type: "text", value: " if " },
|
||||
})
|
||||
})
|
||||
|
||||
test("bounds source and recursive macro expansion", () => {
|
||||
expect(() => parseLatex("12345", { maxSourceLength: 4 })).toThrow(/4-character limit/)
|
||||
expect(() =>
|
||||
parseLatex(String.raw`\a`, {
|
||||
macros: { a: String.raw`\a\a` },
|
||||
maxExpandedLength: 64,
|
||||
}),
|
||||
).toThrow(/64-character limit/)
|
||||
expect(() => parseLatex(String.raw`\a`, { macros: { a: "{{x}}" }, maxDepth: 1 })).toThrow(/1-level limit/)
|
||||
expect(() => parseLatex("x", { maxSourceLength: 0 })).toThrow(RangeError)
|
||||
})
|
||||
|
||||
test("fails quickly when malformed environments cannot advance", () => {
|
||||
expect(() => parseLatex(String.raw`\begin{matrix}]`)).toThrow(/Missing \\end{matrix}/)
|
||||
expect(() => parseLatex(String.raw`\begin{matrix}x}`)).toThrow(/Unexpected "}" in matrix/)
|
||||
expect(() => parseLatex(String.raw`\begin{matrix}&}`)).toThrow(/Unexpected "}" in matrix/)
|
||||
})
|
||||
|
||||
test("bounds structural nesting with a parse error instead of overflowing the stack", () => {
|
||||
const source = "{".repeat(80) + "x" + "}".repeat(80)
|
||||
expect(() => parseLatex(source, { maxDepth: 64 })).toThrow(/64-level limit/)
|
||||
expect(() => parseLatex(String.raw`\frac`.repeat(80) + "x", { maxDepth: 64 })).toThrow(/64-level limit/)
|
||||
})
|
||||
})
|
||||
@@ -1,598 +0,0 @@
|
||||
import {
|
||||
LatexParseError,
|
||||
type AccentKind,
|
||||
type MathNode,
|
||||
type MathVariant,
|
||||
type MatrixEnvironment,
|
||||
type ParseOptions,
|
||||
} from "./types"
|
||||
import {
|
||||
assertNestingDepth,
|
||||
assertSourceLength,
|
||||
DEFAULT_MAX_NESTING_DEPTH,
|
||||
DEFAULT_MAX_SOURCE_LENGTH,
|
||||
resolvePositiveInteger,
|
||||
} from "./limits"
|
||||
import { delimiterTable, largeOperators, namedOperators, spacingCommands, symbolTable } from "./symbols"
|
||||
|
||||
const matrixEnvironments: MatrixEnvironment[] = [
|
||||
"matrix",
|
||||
"pmatrix",
|
||||
"bmatrix",
|
||||
"Bmatrix",
|
||||
"vmatrix",
|
||||
"Vmatrix",
|
||||
"cases",
|
||||
"aligned",
|
||||
"align",
|
||||
"gathered",
|
||||
"gather",
|
||||
"smallmatrix",
|
||||
"array",
|
||||
]
|
||||
|
||||
const accents: Readonly<Record<string, AccentKind>> = {
|
||||
hat: "hat",
|
||||
widehat: "widehat",
|
||||
bar: "bar",
|
||||
overline: "overline",
|
||||
underline: "underline",
|
||||
vec: "vec",
|
||||
tilde: "tilde",
|
||||
widetilde: "tilde",
|
||||
dot: "dot",
|
||||
ddot: "ddot",
|
||||
}
|
||||
|
||||
const variants: Readonly<Record<string, MathVariant>> = {
|
||||
mathrm: "normal",
|
||||
textrm: "normal",
|
||||
mathnormal: "normal",
|
||||
mathbf: "bold",
|
||||
boldsymbol: "bold",
|
||||
bm: "bold",
|
||||
mathit: "italic",
|
||||
mathsf: "sans",
|
||||
mathtt: "monospace",
|
||||
mathbb: "double-struck",
|
||||
mathcal: "script",
|
||||
mathscr: "script",
|
||||
mathfrak: "fraktur",
|
||||
}
|
||||
|
||||
export function parseLatex(source: string, options: ParseOptions = {}): MathNode {
|
||||
const expanded = expandLatexMacros(source, options)
|
||||
const maxDepth = resolvePositiveInteger(options.maxDepth, DEFAULT_MAX_NESTING_DEPTH, "maxDepth")
|
||||
return new Parser(expanded, options.strict ?? false, maxDepth).parse()
|
||||
}
|
||||
|
||||
export function expandLatexMacros(source: string, options: ParseOptions = {}): string {
|
||||
const maxSourceLength = resolvePositiveInteger(options.maxSourceLength, DEFAULT_MAX_SOURCE_LENGTH, "maxSourceLength")
|
||||
const maxExpandedLength = resolvePositiveInteger(options.maxExpandedLength, maxSourceLength, "maxExpandedLength")
|
||||
const maxExpand = resolvePositiveInteger(options.maxExpand, 100, "maxExpand")
|
||||
const maxDepth = resolvePositiveInteger(options.maxDepth, DEFAULT_MAX_NESTING_DEPTH, "maxDepth")
|
||||
assertSourceLength(source, maxSourceLength)
|
||||
assertNestingDepth(source, maxDepth)
|
||||
const expanded = expandMacros(source, options.macros, maxExpand, maxExpandedLength)
|
||||
if (expanded !== source) assertNestingDepth(expanded, maxDepth)
|
||||
return expanded
|
||||
}
|
||||
|
||||
function expandMacros(
|
||||
source: string,
|
||||
macros: ParseOptions["macros"],
|
||||
maxExpand: number,
|
||||
maxExpandedLength: number,
|
||||
): string {
|
||||
assertSourceLength(source, maxExpandedLength, "Expanded LaTeX source")
|
||||
if (!macros || Object.keys(macros).length === 0) return source
|
||||
|
||||
let result = source
|
||||
for (let pass = 0; pass < maxExpand; pass++) {
|
||||
let changed = false
|
||||
let cursor = 0
|
||||
let outputLength = 0
|
||||
const output: string[] = []
|
||||
const commands = /\\[A-Za-z@]+|\\./g
|
||||
|
||||
for (const match of result.matchAll(commands)) {
|
||||
const command = match[0]
|
||||
const index = match.index
|
||||
const replacement = macros[command] ?? macros[command.slice(1)]
|
||||
if (replacement === undefined) continue
|
||||
if (typeof replacement !== "string") {
|
||||
throw new TypeError(`Macro ${command} must expand to a string`)
|
||||
}
|
||||
|
||||
appendWithinLimit(output, result.slice(cursor, index), outputLength, maxExpandedLength)
|
||||
outputLength += index - cursor
|
||||
appendWithinLimit(output, replacement, outputLength, maxExpandedLength)
|
||||
outputLength += replacement.length
|
||||
cursor = index + command.length
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (!changed) return result
|
||||
appendWithinLimit(output, result.slice(cursor), outputLength, maxExpandedLength)
|
||||
result = output.join("")
|
||||
}
|
||||
|
||||
throw new LatexParseError(`Macro expansion exceeded ${maxExpand} passes`, 0)
|
||||
}
|
||||
|
||||
function appendWithinLimit(output: string[], value: string, currentLength: number, maximum: number): void {
|
||||
if (currentLength + value.length > maximum) {
|
||||
throw new LatexParseError(`Expanded LaTeX source exceeds the ${maximum}-character limit`, maximum)
|
||||
}
|
||||
output.push(value)
|
||||
}
|
||||
|
||||
class Parser {
|
||||
private position = 0
|
||||
private depth = 0
|
||||
|
||||
constructor(
|
||||
private readonly source: string,
|
||||
private readonly strict: boolean,
|
||||
private readonly maxDepth: number,
|
||||
) {}
|
||||
|
||||
public parse(): MathNode {
|
||||
const body = this.parseRow()
|
||||
this.skipMathWhitespace()
|
||||
if (!this.done()) this.fail(`Unexpected "${this.peek()}"`)
|
||||
return row(body)
|
||||
}
|
||||
|
||||
private parseRow(stop?: () => boolean): MathNode[] {
|
||||
const body: MathNode[] = []
|
||||
|
||||
while (!this.done()) {
|
||||
this.skipMathWhitespace()
|
||||
if (this.done() || stop?.()) break
|
||||
|
||||
const current = this.peek()
|
||||
if (current === "}") break
|
||||
|
||||
if (current === "^" || current === "_") {
|
||||
this.position++
|
||||
const script = this.parseArgument()
|
||||
const previous = body.pop() ?? { type: "row", body: [] }
|
||||
const existing = previous.type === "scripts" ? previous : { type: "scripts" as const, base: previous }
|
||||
if (current === "^") existing.superscript = script
|
||||
else existing.subscript = script
|
||||
body.push(existing)
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === "\\" && this.applyLimitsModifier(body)) continue
|
||||
body.push(this.parseAtom())
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
private parseAtom(): MathNode {
|
||||
this.depth++
|
||||
if (this.depth > this.maxDepth) {
|
||||
this.depth--
|
||||
this.fail(`LaTeX nesting exceeds the ${this.maxDepth}-level limit`)
|
||||
}
|
||||
try {
|
||||
return this.parseAtomInner()
|
||||
} finally {
|
||||
this.depth--
|
||||
}
|
||||
}
|
||||
|
||||
private parseAtomInner(): MathNode {
|
||||
const current = this.peek()
|
||||
if (current === "{") return this.parseGroup()
|
||||
if (current === "\\") return this.parseCommand()
|
||||
if (current === "~") {
|
||||
this.position++
|
||||
return { type: "space", width: 1 }
|
||||
}
|
||||
|
||||
this.position++
|
||||
return { type: "symbol", value: current, role: inferRole(current) }
|
||||
}
|
||||
|
||||
private parseCommand(): MathNode {
|
||||
const start = this.position
|
||||
const command = this.readCommand()
|
||||
|
||||
if (command === "\\") return { type: "row", body: [] }
|
||||
if (command === "begin") return this.parseEnvironment()
|
||||
if (command === "frac" || command === "dfrac" || command === "tfrac" || command === "cfrac") {
|
||||
this.skipMathWhitespace()
|
||||
const alignment =
|
||||
command === "cfrac" && this.peek() === "[" ? /^\[([lr]?)\]/.exec(this.source.slice(this.position)) : undefined
|
||||
if (alignment === null) this.fail("Unsupported \\cfrac alignment; expected [l], [r], or []")
|
||||
if (alignment) this.position += alignment[0].length
|
||||
return {
|
||||
type: "fraction",
|
||||
numerator: this.parseArgument(),
|
||||
denominator: this.parseArgument(),
|
||||
bar: true,
|
||||
...(alignment?.[1] ? { numeratorAlign: alignment[1] === "l" ? "left" : "right" } : {}),
|
||||
}
|
||||
}
|
||||
if (command === "binom" || command === "dbinom" || command === "tbinom") {
|
||||
const fraction: MathNode = {
|
||||
type: "fraction",
|
||||
numerator: this.parseArgument(),
|
||||
denominator: this.parseArgument(),
|
||||
bar: false,
|
||||
}
|
||||
return { type: "delimited", left: "(", body: fraction, right: ")" }
|
||||
}
|
||||
if (command === "sqrt") {
|
||||
const index = this.parseOptionalArgument()
|
||||
const result: MathNode = { type: "root", body: this.parseArgument() }
|
||||
if (index) result.index = index
|
||||
return result
|
||||
}
|
||||
if (command === "left") return this.parseLeftRight()
|
||||
if (command === "middle") return { type: "symbol", value: this.readDelimiter() }
|
||||
if (command === "right") {
|
||||
this.position = start
|
||||
this.fail("Unexpected \\right")
|
||||
}
|
||||
if (command in accents) {
|
||||
return { type: "accent", accent: accents[command], body: this.parseArgument() }
|
||||
}
|
||||
if (command in variants) {
|
||||
return {
|
||||
type: "variant",
|
||||
variant: variants[command],
|
||||
body: command === "textrm" ? { type: "text", value: this.readTextGroup() } : this.parseArgument(),
|
||||
}
|
||||
}
|
||||
if (command === "text" || command === "mbox") return { type: "text", value: this.readTextGroup() }
|
||||
if (command === "operatorname") {
|
||||
const limits = this.peek() === "*"
|
||||
if (limits) this.position++
|
||||
return { type: "operator", value: this.readTextGroup(), limits }
|
||||
}
|
||||
if (command === "overset" || command === "stackrel") {
|
||||
const over = this.parseArgument()
|
||||
const base = this.parseArgument()
|
||||
return { type: "overunder", base, over }
|
||||
}
|
||||
if (command === "underset") {
|
||||
const under = this.parseArgument()
|
||||
const base = this.parseArgument()
|
||||
return { type: "overunder", base, under }
|
||||
}
|
||||
if (command === "overbrace" || command === "underbrace") {
|
||||
return { type: "brace", body: this.parseArgument(), position: command === "overbrace" ? "over" : "under" }
|
||||
}
|
||||
if (command === "textcolor") {
|
||||
const color = this.readRawGroup()
|
||||
return { type: "color", color, body: this.parseArgument() }
|
||||
}
|
||||
if (command === "color") {
|
||||
const color = this.readRawGroup()
|
||||
return { type: "color", color, body: row(this.parseRow()) }
|
||||
}
|
||||
if (command === "not") {
|
||||
const target = this.parseAtom()
|
||||
if (target.type === "symbol") return { ...target, value: negateSymbol(target.value) }
|
||||
return { type: "row", body: [{ type: "symbol", value: "¬" }, target] }
|
||||
}
|
||||
if (command === "pmod") {
|
||||
return {
|
||||
type: "row",
|
||||
body: [
|
||||
{ type: "space", width: 1 },
|
||||
{ type: "text", value: "(mod " },
|
||||
this.parseArgument(),
|
||||
{ type: "text", value: ")" },
|
||||
],
|
||||
}
|
||||
}
|
||||
if (command === "mod" || command === "bmod") return { type: "operator", value: "mod", limits: false }
|
||||
if (command === "displaylines") {
|
||||
this.skipMathWhitespace()
|
||||
this.expect("{")
|
||||
return this.parseMatrix("gathered", "}")
|
||||
}
|
||||
if (
|
||||
command === "limits" ||
|
||||
command === "nolimits" ||
|
||||
command === "displaystyle" ||
|
||||
command === "textstyle" ||
|
||||
command === "scriptstyle" ||
|
||||
command === "scriptscriptstyle"
|
||||
) {
|
||||
return { type: "row", body: [] }
|
||||
}
|
||||
if (/^(?:big|Big|bigg|Bigg)[lrm]?$/.test(command)) {
|
||||
return { type: "symbol", value: this.readDelimiter() }
|
||||
}
|
||||
if (command in spacingCommands) return { type: "space", width: spacingCommands[command] }
|
||||
if (command in symbolTable) {
|
||||
const symbol = symbolTable[command]
|
||||
return { type: "symbol", value: symbol.value, ...(symbol.role ? { role: symbol.role } : {}) }
|
||||
}
|
||||
if (command in largeOperators) {
|
||||
return { type: "operator", value: largeOperators[command], limits: !command.includes("int") }
|
||||
}
|
||||
if (namedOperators.has(command)) {
|
||||
return {
|
||||
type: "operator",
|
||||
value: command,
|
||||
limits: command.startsWith("lim") || command === "min" || command === "max",
|
||||
}
|
||||
}
|
||||
if (command === "backslash") return { type: "symbol", value: "\\" }
|
||||
const delimiter = delimiterTable[`\\${command}`] ?? delimiterTable[command]
|
||||
if (delimiter !== undefined) return { type: "symbol", value: delimiter }
|
||||
if (command === "{" || command === "}") return { type: "symbol", value: command }
|
||||
if (command === "%" || command === "#" || command === "$" || command === "&" || command === "_") {
|
||||
return { type: "symbol", value: command }
|
||||
}
|
||||
|
||||
if (this.strict) this.fail(`Unsupported command \\${command}`, start)
|
||||
return { type: "text", value: `\\${command}` }
|
||||
}
|
||||
|
||||
private parseEnvironment(): MathNode {
|
||||
const rawEnvironment = this.readRawGroup()
|
||||
const unstarredEnvironment = rawEnvironment.endsWith("*") ? rawEnvironment.slice(0, -1) : rawEnvironment
|
||||
const environment = matrixEnvironments.find((name) => name === unstarredEnvironment)
|
||||
if (!environment) {
|
||||
if (this.strict) this.fail(`Unsupported environment ${unstarredEnvironment}`)
|
||||
const content = this.readUntilEnd(rawEnvironment)
|
||||
return { type: "text", value: content }
|
||||
}
|
||||
const columns = environment === "array" ? this.readRawGroup().replace(/\s/g, "") : undefined
|
||||
if (columns !== undefined && (!/^[lcr|]+$/.test(columns) || !/[lcr]/.test(columns))) {
|
||||
this.fail("Unsupported array columns; expected l, c, r, and |")
|
||||
}
|
||||
return this.parseMatrix(environment, `\\end{${rawEnvironment}}`, columns)
|
||||
}
|
||||
|
||||
private parseMatrix(environment: MatrixEnvironment, end: string, columns?: string): MathNode {
|
||||
const rows: MathNode[][] = []
|
||||
let cells: MathNode[] = []
|
||||
|
||||
while (!this.done()) {
|
||||
this.skipMathWhitespace()
|
||||
if (this.source.startsWith(end, this.position) && cells.length === 0) break
|
||||
|
||||
const cellStart = this.position
|
||||
const cell = row(
|
||||
this.parseRow(
|
||||
() =>
|
||||
this.peek() === "&" ||
|
||||
this.source.startsWith("\\\\", this.position) ||
|
||||
this.source.startsWith(end, this.position),
|
||||
),
|
||||
)
|
||||
cells.push(cell)
|
||||
this.skipMathWhitespace()
|
||||
|
||||
if (this.peek() === "&") {
|
||||
this.position++
|
||||
continue
|
||||
}
|
||||
if (this.source.startsWith("\\\\", this.position)) {
|
||||
this.position += 2
|
||||
this.consumeOptionalBracket()
|
||||
rows.push(cells)
|
||||
cells = []
|
||||
continue
|
||||
}
|
||||
if (this.source.startsWith(end, this.position)) break
|
||||
// Empty cells are valid only when a cell, row, or closing delimiter advances the parser.
|
||||
if (this.position === cellStart) this.fail(`Unexpected "${this.peek()}" in ${environment}`)
|
||||
}
|
||||
|
||||
if (!this.source.startsWith(end, this.position)) this.fail(`Missing ${end}`)
|
||||
this.expect(end)
|
||||
if (cells.length > 0 || rows.length === 0) rows.push(cells)
|
||||
return { type: "matrix", rows, environment, ...(columns !== undefined ? { columns } : {}) }
|
||||
}
|
||||
|
||||
private parseLeftRight(): MathNode {
|
||||
const left = this.readDelimiter()
|
||||
const atRight = () =>
|
||||
this.source.startsWith("\\right", this.position) && !/[A-Za-z@]/.test(this.source[this.position + 6] ?? "")
|
||||
const body = row(this.parseRow(atRight))
|
||||
if (!atRight()) this.fail("Missing \\right")
|
||||
this.readCommand()
|
||||
const right = this.readDelimiter()
|
||||
return { type: "delimited", left, body, right }
|
||||
}
|
||||
|
||||
private parseArgument(): MathNode {
|
||||
this.skipMathWhitespace()
|
||||
if (this.peek() === "{") return this.parseGroup()
|
||||
if (this.done()) this.fail("Expected an argument")
|
||||
return this.parseAtom()
|
||||
}
|
||||
|
||||
private parseGroup(): MathNode {
|
||||
this.expect("{")
|
||||
const body = row(this.parseRow())
|
||||
this.expect("}")
|
||||
return body
|
||||
}
|
||||
|
||||
private parseOptionalArgument(): MathNode | undefined {
|
||||
this.skipMathWhitespace()
|
||||
if (this.peek() !== "[") return undefined
|
||||
this.position++
|
||||
const body = row(this.parseRow(() => this.peek() === "]"))
|
||||
this.expect("]")
|
||||
return body
|
||||
}
|
||||
|
||||
private consumeOptionalBracket(): void {
|
||||
this.skipMathWhitespace()
|
||||
if (this.peek() !== "[") return
|
||||
let depth = 0
|
||||
while (!this.done()) {
|
||||
const char = this.source[this.position++]
|
||||
if (char === "[") depth++
|
||||
if (char === "]" && --depth === 0) return
|
||||
}
|
||||
}
|
||||
|
||||
private readDelimiter(): string {
|
||||
this.skipMathWhitespace()
|
||||
if (this.done()) this.fail("Expected a delimiter")
|
||||
const start = this.position
|
||||
if (this.peek() === "\\") {
|
||||
const command = this.readCommand()
|
||||
const delimiter = delimiterTable[`\\${command}`] ?? delimiterTable[command]
|
||||
if (delimiter !== undefined) return delimiter
|
||||
if (this.strict) this.fail(`Unsupported delimiter \\${command}`, start)
|
||||
return `\\${command}`
|
||||
}
|
||||
const token = this.source[this.position++]
|
||||
return delimiterTable[token] ?? token
|
||||
}
|
||||
|
||||
private readCommand(): string {
|
||||
this.expect("\\")
|
||||
if (this.done()) return "\\"
|
||||
const next = this.peek()
|
||||
if (!/[A-Za-z@]/.test(next)) {
|
||||
this.position++
|
||||
return next
|
||||
}
|
||||
|
||||
const start = this.position
|
||||
while (!this.done() && /[A-Za-z@]/.test(this.peek())) this.position++
|
||||
const command = this.source.slice(start, this.position)
|
||||
if (this.peek() === " ") this.position++
|
||||
return command
|
||||
}
|
||||
|
||||
private readRawGroup(): string {
|
||||
this.skipMathWhitespace()
|
||||
this.expect("{")
|
||||
const start = this.position
|
||||
let depth = 1
|
||||
while (!this.done()) {
|
||||
const char = this.source[this.position++]
|
||||
const escaped = (char === "{" || char === "}") && this.isEscaped(this.position - 1)
|
||||
if (char === "{" && !escaped) depth++
|
||||
if (char === "}" && !escaped && --depth === 0) return this.source.slice(start, this.position - 1)
|
||||
}
|
||||
return this.fail("Unterminated group", start)
|
||||
}
|
||||
|
||||
private applyLimitsModifier(body: MathNode[]): boolean {
|
||||
const match = /^\\(limits|nolimits)(?![A-Za-z@])/.exec(this.source.slice(this.position))
|
||||
if (!match) return false
|
||||
this.position += match[0].length
|
||||
|
||||
const target = body.at(-1)
|
||||
const operator =
|
||||
target?.type === "operator"
|
||||
? target
|
||||
: target?.type === "scripts" && target.base.type === "operator"
|
||||
? target.base
|
||||
: undefined
|
||||
if (operator) operator.limits = match[1] === "limits"
|
||||
return true
|
||||
}
|
||||
|
||||
private isEscaped(index: number): boolean {
|
||||
let slashCount = 0
|
||||
for (let cursor = index - 1; cursor >= 0 && this.source[cursor] === "\\"; cursor--) slashCount++
|
||||
return slashCount % 2 === 1
|
||||
}
|
||||
|
||||
private readTextGroup(): string {
|
||||
return this.readRawGroup()
|
||||
.replace(/\\([A-Za-z@]+|.)/g, (match, command: string) => {
|
||||
if ("{}%#$&_ ".includes(command)) return command
|
||||
if (command === "textbackslash") return "\\"
|
||||
if (command === "!") return ""
|
||||
if (command in spacingCommands) return " ".repeat(Math.max(1, spacingCommands[command]))
|
||||
return match
|
||||
})
|
||||
.replace(/~/g, " ")
|
||||
}
|
||||
|
||||
private readUntilEnd(environment: string): string {
|
||||
const marker = `\\end{${environment}}`
|
||||
const end = this.source.indexOf(marker, this.position)
|
||||
if (end < 0) this.fail(`Missing ${marker}`)
|
||||
const content = this.source.slice(this.position, end)
|
||||
this.position = end + marker.length
|
||||
return content
|
||||
}
|
||||
|
||||
private skipMathWhitespace(): void {
|
||||
while (!this.done()) {
|
||||
if (/\s/.test(this.peek())) {
|
||||
this.position++
|
||||
continue
|
||||
}
|
||||
if (this.peek() === "%") {
|
||||
while (!this.done() && this.peek() !== "\n") this.position++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private expect(value: string): void {
|
||||
if (!this.source.startsWith(value, this.position)) this.fail(`Expected "${value}"`)
|
||||
this.position += value.length
|
||||
}
|
||||
|
||||
private peek(): string {
|
||||
return this.source[this.position] ?? ""
|
||||
}
|
||||
|
||||
private done(): boolean {
|
||||
return this.position >= this.source.length
|
||||
}
|
||||
|
||||
private fail(message: string, position = this.position): never {
|
||||
throw new LatexParseError(message, position)
|
||||
}
|
||||
}
|
||||
|
||||
function row(body: MathNode[]): MathNode {
|
||||
if (body.length === 1) return body[0]
|
||||
return { type: "row", body }
|
||||
}
|
||||
|
||||
function inferRole(value: string): "binary" | "relation" | "punctuation" | "opening" | "closing" | "ordinary" {
|
||||
if ("+-*/×÷±∓".includes(value)) return "binary"
|
||||
if ("=<>≤≥≠≈∈∉⊂⊃".includes(value)) return "relation"
|
||||
if (",;:".includes(value)) return "punctuation"
|
||||
if ("([{".includes(value)) return "opening"
|
||||
if (")]}".includes(value)) return "closing"
|
||||
return "ordinary"
|
||||
}
|
||||
|
||||
function negateSymbol(value: string): string {
|
||||
const negated: Record<string, string> = {
|
||||
"=": "≠",
|
||||
"∈": "∉",
|
||||
"∋": "∌",
|
||||
"≡": "≢",
|
||||
"≈": "≉",
|
||||
"∼": "≁",
|
||||
"<": "≮",
|
||||
">": "≯",
|
||||
"≤": "≰",
|
||||
"≥": "≱",
|
||||
"⊂": "⊄",
|
||||
"⊃": "⊅",
|
||||
"⊆": "⊈",
|
||||
"⊇": "⊉",
|
||||
"∣": "∤",
|
||||
"∥": "∦",
|
||||
}
|
||||
return negated[value] ?? `${value}̸`
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createLatexCodeBlockRenderer } from "./markdown"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.latex",
|
||||
setup(context) {
|
||||
const render = createLatexCodeBlockRenderer(context.renderer, () => ({
|
||||
text: context.theme.text.default,
|
||||
subdued: context.theme.text.subdued,
|
||||
}))
|
||||
context.markdown.registerCodeBlockRenderer("latex", render)
|
||||
context.markdown.registerCodeBlockRenderer("math", render)
|
||||
},
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { renderLatex, renderLatexToString } from "./render"
|
||||
|
||||
describe("renderLatexToString", () => {
|
||||
test("renders a fraction with a centered rule", () => {
|
||||
expect(renderLatexToString(String.raw`\frac{x+1}{y-1}`)).toBe([" x + 1", "───────", " y - 1"].join("\n"))
|
||||
})
|
||||
|
||||
test.each([
|
||||
[String.raw`E = mc^2`, "E = mc²"],
|
||||
[String.raw`a_n`, "aₙ"],
|
||||
[String.raw`x_i^2`, "x²ᵢ"],
|
||||
[String.raw`x^{}`, "x"],
|
||||
[String.raw`x_{}`, "x"],
|
||||
[String.raw`x^{}_{}`, "x"],
|
||||
[String.raw`x^m_1`, " m\nx\n 1"],
|
||||
[String.raw`x^2_q`, " 2\nx\n q"],
|
||||
[String.raw`x^{\frac{1}{2}}_1`, " 1\n ───\n 2\nx\n 1"],
|
||||
])("compacts scripts only when every script is supported: %s", (source, expected) => {
|
||||
expect(renderLatexToString(source)).toBe(expected)
|
||||
})
|
||||
|
||||
test("respects script and display mode options", () => {
|
||||
expect(renderLatexToString(String.raw`x_i^2`, { compactScripts: false })).toBe(" 2\nx\n i")
|
||||
expect(renderLatexToString(String.raw`\sum_1^n`, { displayMode: false })).toBe("∑ⁿ₁")
|
||||
expect(renderLatexToString(String.raw`\sum_1^n`, { compactScripts: false })).toBe("n\n∑\n1")
|
||||
})
|
||||
|
||||
test("centers binomials around an empty math-axis row", () => {
|
||||
expect(renderLatexToString(String.raw`P = \binom{n}{k}`)).toBe([" ⎛ n ⎞", "P = ⎜ ⎟", " ⎝ k ⎠"].join("\n"))
|
||||
})
|
||||
|
||||
test("renders roots with a vinculum", () => {
|
||||
expect(renderLatexToString(String.raw`\sqrt{x^2+y^2}`)).toBe([" ╭───────", "╰╯x² + y²"].join("\n"))
|
||||
})
|
||||
|
||||
test("renders matrices with stretching delimiters", () => {
|
||||
expect(renderLatexToString(String.raw`\begin{pmatrix}a & b \\ c & d\end{pmatrix}`)).toBe(
|
||||
["⎛a b⎞", "⎜ ⎟", "⎝c d⎠"].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("places display operator limits above and below", () => {
|
||||
expect(renderLatexToString(String.raw`\sum_{i=1}^{n} i^2`)).toBe([" n", " ∑ i²", "i = 1"].join("\n"))
|
||||
})
|
||||
|
||||
test("returns intrinsic geometry and baseline", () => {
|
||||
const layout = renderLatex(String.raw`\frac{1}{2}`)
|
||||
expect(layout.width).toBe(3)
|
||||
expect(layout.height).toBe(3)
|
||||
expect(layout.baseline).toBe(1)
|
||||
})
|
||||
|
||||
test("renders blackboard, calligraphic, and fraktur alphabets", () => {
|
||||
expect(renderLatexToString(String.raw`\mathbb{R} \to \mathcal{C} \times \mathfrak{g}`)).toBe("ℝ → 𝒞 × 𝔤")
|
||||
})
|
||||
|
||||
test("preserves inherited styles through nested variants and colors", () => {
|
||||
const layout = renderLatex(String.raw`\mathbf{\mathsf{\textcolor{red}{\mathit{x}}}}`)
|
||||
expect(layout.cells[0][0]).toEqual({ char: "x", style: { bold: true, italic: true, color: "red" } })
|
||||
})
|
||||
|
||||
test("renders nested fractions without flattening their structure", () => {
|
||||
const result = renderLatexToString(String.raw`\frac{1}{1+\frac{1}{x}}`)
|
||||
expect(result.split("\n")).toHaveLength(5)
|
||||
expect(result.match(/─/g)?.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test("renders common textbook structures", () => {
|
||||
const result = renderLatexToString(String.raw`\left[\frac{-b \pm \sqrt{b^2-4ac}}{2a}\right]`)
|
||||
expect(result).toContain("±")
|
||||
expect(result).toContain("╰╯")
|
||||
expect(result).toContain("─")
|
||||
expect(result).toContain("⎡")
|
||||
expect(result).toContain("⎦")
|
||||
})
|
||||
|
||||
test("places fallback combining negation after the base symbol", () => {
|
||||
const result = renderLatexToString(String.raw`\not\rightarrow`)
|
||||
expect(Array.from(result)).toEqual(["→", "̸"])
|
||||
})
|
||||
|
||||
test("treats square brackets as ordinary interval delimiters", () => {
|
||||
expect(renderLatexToString(String.raw`x\in[0,1]`)).toBe("x ∈ [0,1]")
|
||||
expect(renderLatexToString(String.raw`[-1,1]`)).toBe("[-1,1]")
|
||||
})
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
import { layoutMath } from "./layout"
|
||||
import { parseLatex } from "./parser"
|
||||
import type { MathLayout, RenderLatexOptions } from "./types"
|
||||
|
||||
export function renderLatex(source: string, options: RenderLatexOptions = {}): MathLayout {
|
||||
return layoutMath(parseLatex(source, options), options)
|
||||
}
|
||||
|
||||
export function renderLatexToString(source: string, options: RenderLatexOptions = {}): string {
|
||||
return renderLatex(source, options).toString()
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { renderLatex } from "./render"
|
||||
|
||||
describe("root geometry", () => {
|
||||
test.each([
|
||||
["x", String.raw`\frac{1}{2}`],
|
||||
["x", String.raw`\sqrt{n}`],
|
||||
["x", "123456789"],
|
||||
["x", String.raw`\frac{123456789}{\frac{n}{m}}`],
|
||||
[String.raw`\frac{a}{b}`, "3"],
|
||||
[String.raw`\sqrt{\frac{a}{b}}`, String.raw`\sqrt{\frac{n}{m}}`],
|
||||
[String.raw`\text{界}`, String.raw`\text{次}`],
|
||||
["", String.raw`\frac{1}{2}`],
|
||||
])("preserves body %s and index %s", (bodySource, indexSource) => {
|
||||
const body = renderLatex(bodySource, { color: "red" })
|
||||
const index = renderLatex(indexSource, { color: "blue" })
|
||||
const root = renderLatex(String.raw`\sqrt[\textcolor{blue}{${indexSource}}]{\textcolor{red}{${bodySource}}}`)
|
||||
const bodyX = root.width - body.width
|
||||
const bodyY = root.height - body.height
|
||||
|
||||
expect(root.cells).toHaveLength(root.height)
|
||||
expect(root.baseline).toBe(bodyY + body.baseline)
|
||||
expect(bodyX).toBeGreaterThan(index.width)
|
||||
expect(bodyY).toBeGreaterThanOrEqual(index.height)
|
||||
for (const row of root.cells) expect(row).toHaveLength(root.width)
|
||||
for (const [y, row] of body.cells.entries()) {
|
||||
for (const [x, cell] of row.entries()) expect(root.cells[bodyY + y][bodyX + x]).toEqual(cell)
|
||||
}
|
||||
for (const [y, row] of index.cells.entries()) {
|
||||
for (const [x, cell] of row.entries()) expect(root.cells[y][x]).toEqual(cell)
|
||||
}
|
||||
expect(root.cells.flat().filter((cell) => cell?.style?.color === "red")).toHaveLength(
|
||||
body.cells.flat().filter(Boolean).length,
|
||||
)
|
||||
expect(root.cells.flat().filter((cell) => cell?.style?.color === "blue")).toHaveLength(
|
||||
index.cells.flat().filter(Boolean).length,
|
||||
)
|
||||
})
|
||||
|
||||
test("connects each nested overbar to a full-height stem", () => {
|
||||
const root = renderLatex(String.raw`\sqrt{\sqrt{\sqrt{x}}}`)
|
||||
expect(root.toString()).toBe([" ╭─────", " │ ╭───", " │ │ ╭─", "╰╯╰╯╰╯x"].join("\n"))
|
||||
expect(root.height).toBe(4)
|
||||
expect(root.baseline).toBe(3)
|
||||
for (const depth of [0, 1, 2]) {
|
||||
for (let y = depth; y < root.height; y++) expect(root.cells[y][depth * 2 + 1]).toBeDefined()
|
||||
}
|
||||
expect(root.cells.flat().filter((cell) => cell?.char === "x")).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("extends a fraction root below the math axis without moving its baseline", () => {
|
||||
const root = renderLatex(String.raw`\sqrt{\frac{a}{b}}`)
|
||||
expect(root.toString()).toBe([" ╭───", " │ a", " │───", "╰╯ b"].join("\n"))
|
||||
expect(root.height).toBe(4)
|
||||
expect(root.baseline).toBe(2)
|
||||
for (let y = 0; y < root.height; y++) expect(root.cells[y][1]).toBeDefined()
|
||||
expect(root.cells[root.baseline].map((cell) => cell?.char ?? " ").join("")).toContain("───")
|
||||
expect(root.cells[root.height - 1].some((cell) => cell?.char === "b")).toBe(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
[String.raw`\sqrt{x}`, [" ╭─", "╰╯x"]],
|
||||
[String.raw`\sqrt[3]{x}`, ["3╭─", "╰╯x"]],
|
||||
[String.raw`\sqrt[\frac{1}{2}]{x}`, [" 1", "───", " 2 ╭─", " ╰╯x"]],
|
||||
[String.raw`\sqrt[\sqrt{n}]{x}`, [" ╭─", "╰╯n╭─", " ╰╯x"]],
|
||||
])("uses the same connected construction for %s", (source, expected) => {
|
||||
expect(renderLatex(source).toString()).toBe(expected.join("\n"))
|
||||
})
|
||||
})
|
||||
@@ -1,309 +0,0 @@
|
||||
import type { SymbolRole } from "./types"
|
||||
|
||||
export interface SymbolDefinition {
|
||||
value: string
|
||||
role?: SymbolRole
|
||||
}
|
||||
|
||||
const ordinary: Record<string, string> = {
|
||||
alpha: "α",
|
||||
beta: "β",
|
||||
gamma: "γ",
|
||||
delta: "δ",
|
||||
epsilon: "ε",
|
||||
varepsilon: "ϵ",
|
||||
zeta: "ζ",
|
||||
eta: "η",
|
||||
theta: "θ",
|
||||
vartheta: "ϑ",
|
||||
iota: "ι",
|
||||
kappa: "κ",
|
||||
lambda: "λ",
|
||||
mu: "μ",
|
||||
nu: "ν",
|
||||
xi: "ξ",
|
||||
omicron: "ο",
|
||||
pi: "π",
|
||||
varpi: "ϖ",
|
||||
rho: "ρ",
|
||||
varrho: "ϱ",
|
||||
sigma: "σ",
|
||||
varsigma: "ς",
|
||||
tau: "τ",
|
||||
upsilon: "υ",
|
||||
phi: "ϕ",
|
||||
varphi: "φ",
|
||||
chi: "χ",
|
||||
psi: "ψ",
|
||||
omega: "ω",
|
||||
Gamma: "Γ",
|
||||
Delta: "Δ",
|
||||
Theta: "Θ",
|
||||
Lambda: "Λ",
|
||||
Xi: "Ξ",
|
||||
Pi: "Π",
|
||||
Sigma: "Σ",
|
||||
Upsilon: "Υ",
|
||||
Phi: "Φ",
|
||||
Psi: "Ψ",
|
||||
Omega: "Ω",
|
||||
infty: "∞",
|
||||
ell: "ℓ",
|
||||
hbar: "ℏ",
|
||||
imath: "ı",
|
||||
jmath: "ȷ",
|
||||
Re: "ℜ",
|
||||
Im: "ℑ",
|
||||
aleph: "ℵ",
|
||||
beth: "ℶ",
|
||||
gimel: "ℷ",
|
||||
daleth: "ℸ",
|
||||
partial: "∂",
|
||||
nabla: "∇",
|
||||
angle: "∠",
|
||||
measuredangle: "∡",
|
||||
triangle: "△",
|
||||
square: "□",
|
||||
lozenge: "◊",
|
||||
top: "⊤",
|
||||
bot: "⊥",
|
||||
emptyset: "∅",
|
||||
varnothing: "∅",
|
||||
forall: "∀",
|
||||
exists: "∃",
|
||||
nexists: "∄",
|
||||
neg: "¬",
|
||||
lnot: "¬",
|
||||
prime: "′",
|
||||
backprime: "‵",
|
||||
clubsuit: "♣",
|
||||
diamondsuit: "♢",
|
||||
heartsuit: "♡",
|
||||
spadesuit: "♠",
|
||||
checkmark: "✓",
|
||||
}
|
||||
|
||||
const binary: Record<string, string> = {
|
||||
pm: "±",
|
||||
mp: "∓",
|
||||
times: "×",
|
||||
div: "÷",
|
||||
cdot: "·",
|
||||
ast: "∗",
|
||||
star: "⋆",
|
||||
circ: "∘",
|
||||
bullet: "∙",
|
||||
oplus: "⊕",
|
||||
ominus: "⊖",
|
||||
otimes: "⊗",
|
||||
oslash: "⊘",
|
||||
odot: "⊙",
|
||||
cap: "∩",
|
||||
cup: "∪",
|
||||
uplus: "⊎",
|
||||
sqcap: "⊓",
|
||||
sqcup: "⊔",
|
||||
vee: "∨",
|
||||
lor: "∨",
|
||||
wedge: "∧",
|
||||
land: "∧",
|
||||
setminus: "∖",
|
||||
wr: "≀",
|
||||
diamond: "⋄",
|
||||
bigtriangleup: "△",
|
||||
bigtriangledown: "▽",
|
||||
triangleleft: "◁",
|
||||
triangleright: "▷",
|
||||
}
|
||||
|
||||
const relation: Record<string, string> = {
|
||||
equals: "=",
|
||||
neq: "≠",
|
||||
ne: "≠",
|
||||
equiv: "≡",
|
||||
approx: "≈",
|
||||
sim: "∼",
|
||||
simeq: "≃",
|
||||
cong: "≅",
|
||||
asymp: "≍",
|
||||
propto: "∝",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
le: "≤",
|
||||
leq: "≤",
|
||||
ge: "≥",
|
||||
geq: "≥",
|
||||
ll: "≪",
|
||||
gg: "≫",
|
||||
prec: "≺",
|
||||
succ: "≻",
|
||||
preceq: "⪯",
|
||||
succeq: "⪰",
|
||||
subset: "⊂",
|
||||
supset: "⊃",
|
||||
subseteq: "⊆",
|
||||
supseteq: "⊇",
|
||||
sqsubset: "⊏",
|
||||
sqsupset: "⊐",
|
||||
sqsubseteq: "⊑",
|
||||
sqsupseteq: "⊒",
|
||||
in: "∈",
|
||||
ni: "∋",
|
||||
notin: "∉",
|
||||
owns: "∋",
|
||||
vdash: "⊢",
|
||||
dashv: "⊣",
|
||||
models: "⊨",
|
||||
mid: "∣",
|
||||
parallel: "∥",
|
||||
perp: "⊥",
|
||||
smile: "⌣",
|
||||
frown: "⌢",
|
||||
}
|
||||
|
||||
const arrows: Record<string, string> = {
|
||||
leftarrow: "←",
|
||||
gets: "←",
|
||||
rightarrow: "→",
|
||||
to: "→",
|
||||
leftrightarrow: "↔",
|
||||
Leftarrow: "⇐",
|
||||
Rightarrow: "⇒",
|
||||
Leftrightarrow: "⇔",
|
||||
mapsto: "↦",
|
||||
hookleftarrow: "↩",
|
||||
hookrightarrow: "↪",
|
||||
leftharpoonup: "↼",
|
||||
leftharpoondown: "↽",
|
||||
rightharpoonup: "⇀",
|
||||
rightharpoondown: "⇁",
|
||||
rightleftharpoons: "⇌",
|
||||
longleftarrow: "⟵",
|
||||
longrightarrow: "⟶",
|
||||
longleftrightarrow: "⟷",
|
||||
Longleftarrow: "⟸",
|
||||
Longrightarrow: "⟹",
|
||||
Longleftrightarrow: "⟺",
|
||||
longmapsto: "⟼",
|
||||
uparrow: "↑",
|
||||
downarrow: "↓",
|
||||
updownarrow: "↕",
|
||||
Uparrow: "⇑",
|
||||
Downarrow: "⇓",
|
||||
Updownarrow: "⇕",
|
||||
nearrow: "↗",
|
||||
searrow: "↘",
|
||||
swarrow: "↙",
|
||||
nwarrow: "↖",
|
||||
}
|
||||
|
||||
const punctuation: Record<string, string> = {
|
||||
cdots: "⋯",
|
||||
ldots: "…",
|
||||
dots: "…",
|
||||
vdots: "⋮",
|
||||
ddots: "⋱",
|
||||
colon: ":",
|
||||
}
|
||||
|
||||
export const symbolTable: Readonly<Record<string, SymbolDefinition>> = {
|
||||
...Object.fromEntries(Object.entries(ordinary).map(([name, value]) => [name, { value, role: "ordinary" as const }])),
|
||||
...Object.fromEntries(Object.entries(binary).map(([name, value]) => [name, { value, role: "binary" as const }])),
|
||||
...Object.fromEntries(Object.entries(relation).map(([name, value]) => [name, { value, role: "relation" as const }])),
|
||||
...Object.fromEntries(Object.entries(arrows).map(([name, value]) => [name, { value, role: "relation" as const }])),
|
||||
...Object.fromEntries(
|
||||
Object.entries(punctuation).map(([name, value]) => [name, { value, role: "punctuation" as const }]),
|
||||
),
|
||||
}
|
||||
|
||||
export const largeOperators: Readonly<Record<string, string>> = {
|
||||
sum: "∑",
|
||||
prod: "∏",
|
||||
coprod: "∐",
|
||||
int: "∫",
|
||||
iint: "∬",
|
||||
iiint: "∭",
|
||||
oint: "∮",
|
||||
bigcap: "⋂",
|
||||
bigcup: "⋃",
|
||||
bigvee: "⋁",
|
||||
bigwedge: "⋀",
|
||||
bigoplus: "⨁",
|
||||
bigotimes: "⨂",
|
||||
bigodot: "⨀",
|
||||
}
|
||||
|
||||
export const namedOperators = new Set([
|
||||
"arccos",
|
||||
"arcsin",
|
||||
"arctan",
|
||||
"arg",
|
||||
"cos",
|
||||
"cosh",
|
||||
"cot",
|
||||
"coth",
|
||||
"csc",
|
||||
"deg",
|
||||
"det",
|
||||
"dim",
|
||||
"exp",
|
||||
"gcd",
|
||||
"hom",
|
||||
"inf",
|
||||
"ker",
|
||||
"lg",
|
||||
"lim",
|
||||
"liminf",
|
||||
"limsup",
|
||||
"ln",
|
||||
"log",
|
||||
"max",
|
||||
"min",
|
||||
"mod",
|
||||
"Pr",
|
||||
"sec",
|
||||
"sin",
|
||||
"sinh",
|
||||
"sup",
|
||||
"tan",
|
||||
"tanh",
|
||||
])
|
||||
|
||||
export const delimiterTable: Readonly<Record<string, string>> = {
|
||||
"(": "(",
|
||||
")": ")",
|
||||
"[": "[",
|
||||
"]": "]",
|
||||
"\\{": "{",
|
||||
"\\}": "}",
|
||||
"{": "{",
|
||||
"}": "}",
|
||||
"|": "│",
|
||||
"\\|": "║",
|
||||
vert: "│",
|
||||
Vert: "║",
|
||||
lvert: "│",
|
||||
rvert: "│",
|
||||
lVert: "║",
|
||||
rVert: "║",
|
||||
lbrace: "{",
|
||||
rbrace: "}",
|
||||
langle: "⟨",
|
||||
rangle: "⟩",
|
||||
lfloor: "⌊",
|
||||
rfloor: "⌋",
|
||||
lceil: "⌈",
|
||||
rceil: "⌉",
|
||||
".": "",
|
||||
}
|
||||
|
||||
export const spacingCommands: Readonly<Record<string, number>> = {
|
||||
",": 0,
|
||||
":": 1,
|
||||
";": 1,
|
||||
"!": 0,
|
||||
quad: 2,
|
||||
qquad: 4,
|
||||
enspace: 1,
|
||||
thinspace: 0,
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
export type MathVariant = "normal" | "bold" | "italic" | "sans" | "monospace" | "double-struck" | "script" | "fraktur"
|
||||
|
||||
export type MathNode =
|
||||
| { type: "row"; body: MathNode[] }
|
||||
| { type: "symbol"; value: string; role?: SymbolRole }
|
||||
| { type: "text"; value: string }
|
||||
| { type: "space"; width: number }
|
||||
| {
|
||||
type: "fraction"
|
||||
numerator: MathNode
|
||||
denominator: MathNode
|
||||
bar: boolean
|
||||
numeratorAlign?: "left" | "right"
|
||||
}
|
||||
| { type: "root"; body: MathNode; index?: MathNode }
|
||||
| { type: "scripts"; base: MathNode; superscript?: MathNode; subscript?: MathNode }
|
||||
| { type: "delimited"; left: string; body: MathNode; right: string }
|
||||
| { type: "matrix"; rows: MathNode[][]; environment: MatrixEnvironment; columns?: string }
|
||||
| { type: "brace"; body: MathNode; position: "over" | "under" }
|
||||
| { type: "accent"; accent: AccentKind; body: MathNode }
|
||||
| { type: "variant"; variant: MathVariant; body: MathNode }
|
||||
| { type: "operator"; value: string; limits: boolean }
|
||||
| { type: "overunder"; base: MathNode; over?: MathNode; under?: MathNode }
|
||||
| { type: "color"; color: string; body: MathNode }
|
||||
|
||||
export type SymbolRole = "ordinary" | "binary" | "relation" | "operator" | "punctuation" | "opening" | "closing"
|
||||
|
||||
export type MatrixEnvironment =
|
||||
| "matrix"
|
||||
| "pmatrix"
|
||||
| "bmatrix"
|
||||
| "Bmatrix"
|
||||
| "vmatrix"
|
||||
| "Vmatrix"
|
||||
| "cases"
|
||||
| "aligned"
|
||||
| "align"
|
||||
| "gathered"
|
||||
| "gather"
|
||||
| "smallmatrix"
|
||||
| "array"
|
||||
|
||||
export type AccentKind = "hat" | "widehat" | "bar" | "overline" | "underline" | "vec" | "tilde" | "dot" | "ddot"
|
||||
|
||||
export interface ParseOptions {
|
||||
macros?: Readonly<Record<string, string>>
|
||||
maxExpand?: number
|
||||
/**
|
||||
* Maximum accepted input length. This guards interactive and AI-generated
|
||||
* formulas against accidentally exhausting the terminal process.
|
||||
*/
|
||||
maxSourceLength?: number
|
||||
/**
|
||||
* Maximum length after user-macro expansion. Defaults to
|
||||
* `maxSourceLength`.
|
||||
*/
|
||||
maxExpandedLength?: number
|
||||
/** Maximum structural nesting depth. */
|
||||
maxDepth?: number
|
||||
strict?: boolean
|
||||
}
|
||||
|
||||
export class LatexParseError extends Error {
|
||||
public readonly position: number
|
||||
|
||||
constructor(message: string, position: number) {
|
||||
super(`${message} at offset ${position}`)
|
||||
this.name = "LatexParseError"
|
||||
this.position = position
|
||||
}
|
||||
}
|
||||
|
||||
export interface MathStyle {
|
||||
color?: string
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
dim?: boolean
|
||||
}
|
||||
|
||||
export interface MathCell {
|
||||
char: string
|
||||
style?: MathStyle
|
||||
}
|
||||
|
||||
export interface MathLayout {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly baseline: number
|
||||
readonly cells: ReadonlyArray<ReadonlyArray<MathCell | undefined>>
|
||||
toString(): string
|
||||
}
|
||||
|
||||
export interface RenderLatexOptions extends ParseOptions {
|
||||
displayMode?: boolean
|
||||
compactScripts?: boolean
|
||||
color?: string
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user